2

Is it possible to have an abstract class require some specific arguments for a method of a Python class, while still leaving the possibility for concrete classes to add more arguments?

from abc import ABC, abstractmethod

class FooBase(ABC):
    @abstractmethod
    def __init__(self, required: str, also_required: int):
        pass

And then the concrete class would go, conceptually:

class Foo(FooBase):
     def __init__(self, required: str, also_required: int, something_else: float):
         do_stuff()

Context

This is in the context of a package/library intended to be imported by client. I'd like to provide the FooBase abstract class, that has a particular contract regarding other parts of the library. Clients would be free to implement their concrete Foo, but in this use case, the method arguments of __init__ are to be considered as "mandatory minimum", not as being exhaustive.

Solution: using **kwargs ?

One "solution" could be to use **kwargs in both abstract and concrete methods to accept any other keyword argument, but the issue is precisely that: the class now accepts any other keyword argument, instead of enforcing specific ones, which brings at least two issues:

  • argument names validation can now happen only at runtime (e.g. no mypy)
  • can't know what argument names to use based on method signature alone
class UglyBase:
    @abstractmethod
    def __init__(self, required: str, **kwargs):
        pass

class Ugly(UglyBase):
    def __init__(self, required: str, **kwargs):
        # yurk
        self.something_else = kwargs.get("something_else", "default")

# small typo in the last argument's name goes undetected
# neither IDE nor MyPy can detect it statically
ugly_object = Ugly(required="hello", someting_else="blabla")

# mistyped argument was silently ignored
ugly_object.something_else
>>> "default"
Jivan
  • 21,522
  • 15
  • 80
  • 131
  • Is a `super().__init__(required)` call a solution? It would be pretty hard to enforce this(code wise) but it would require the user to provide all the required arguments to pass into the super `super().__init__` call. – Robin Dillen Jun 18 '22 at 09:37
  • How about kwarg-only arguments..? `def __init__(self, *, required: str, also_required: int):` – AKX Jun 18 '22 at 09:43
  • @AKX how would it address the issue? – Jivan Jun 18 '22 at 09:44
  • The thing that's missing from this question is example code showing how your library uses `Foo` and/or `FooBase`. If it simply takes a `FooBase` instance and calls methods on it, then the signature of `Foo.__init__` is completely irrelevant. Does it take the actual *class* `Foo` (i.e. a `Type[FooBase]`) and then construct an instance of it? If the concrete `Foo` takes more arguments than the "minimum" ones, how does your library know that it needs to pass those arguments? – Samwise Jun 18 '22 at 19:04

1 Answers1

1

I think this will give you a lead on how to (possibly) solve your issue. I don't know what to think about inspect, but it exists. So why not use it?

from abc import ABC, abstractmethod
import inspect


class FooBase(ABC):
    def __new__(cls, *args, **kwargs):
        # args = ('test', 1, 2.0)
        # cls == Foo
        # inspect.signature(cls.__init__) = <Signature (self, required: str, also_required: int, something_else: float)>
        # You could compare the signature of cls.__init__ with the signature of FooBase.__init__ 
        # for example
        # set(inspect.signature(FooBase.__init__).parameters.keys()).issubset(set(inspect.signature(cls.__init__).parameters.keys()))
        # this is a very long oneliner so you might want to use intermediate variables
        return super(FooBase, cls).__new__(cls)

    @abstractmethod
    def __init__(self, required: str, also_required: int):
        print("init")
        pass


class Foo(FooBase):
    def __init__(self, required: str, also_required: int, something_else: float):
        pass


f = Foo("test", 1, 2.0)
Robin Dillen
  • 704
  • 5
  • 11