Archirk
Archirk

Reputation: 639

What is the right way to mimic interface in Python with AbstractClass and type hinting

I need to write an abstract class which will act like an driver to "something external".

# ./base.py
import typing as t
from abc import ABC, abstractmethod


class DefaultClass: pass

MyType = t.TypeVar("MyType", bound=DefaultClass)

class Driver(ABC):
    def __init__(self, return_class: t.Type[MyType]):
        self.return_class = return_class

    def driver_call(self, param: str) -> MyType:
       return self._driver_call(param)

    @abstractmethod
    def _driver_call(self, param: str) -> MyType:
       raise NotImplementedError()

But here I struggle. Let me try to inherit from this class:

# ./mydriver.py
from base import Driver, DefaultClass


class CustomDriver(Driver):
    def __init__(self, return_class):
        super().__init__(return_class)
    
    def _driver_call(self, param: str) -> ???:
       return self.return_class(param)


# Usage
if __name__ == "__main__":
    class CustomClass(DefaultClass):
        def __init__(self, name: str):
            self.name = name


    cd = CustomDriver(CustomClass)
    instance = cd.driver_call("some_string")

I struggle with next point:

I've read about generics type etc, but currently I can't map info what i read with my goal to make "interface" for driver.

OR may be I am so wrong that there is absolutely another way to do it?

Upvotes: 0

Views: 120

Answers (1)

Daniil Fajnberg
Daniil Fajnberg

Reputation: 18548

You did not give enough context to provide an unambiguous recommendation, but I'll give it a shot.

If that "something external" being driven by your Driver subclasses indeed always inherits from the same base class, there is no need for typing.Protocol here since we have nominal subtyping to guide us.

I agree with @joel that this screams typing.Generic and that _driver_call is redundant (as it appears from your example right now).

The nice thing about abstract base classes is that you typically don't need to write an __init__ method. You can just lay out the attributes and annotate them with their expected types.

I am assuming you know the concrete CustomClass you want to drive with a Driver subclass in advance. A also assume that not all subclasses of DefaultClass will have the same constructor interface, so your abstract Driver cannot mirror the constructor's signature.

from abc import ABC, abstractmethod
from typing import Any, Generic, TypeVar

T = TypeVar("T", bound="DefaultClass")


# External:
class DefaultClass:
    pass


# Generic abstract base class:
class Driver(ABC, Generic[T]):
    return_class: type[T]

    @abstractmethod
    def driver_call(self, *args: Any, **kwargs: Any) -> T:
        ...


# External, but with a specific init signature:
class CustomClass(DefaultClass):
    def __init__(self, name: str) -> None:
        self.name = name


# Specification:
class CustomDriver(Driver[CustomClass]):
    def __init__(self, return_class: type[CustomClass]) -> None:
        self.return_class = return_class

    def driver_call(self, param: str) -> CustomClass:
        return self.return_class(param)

If you want your CustomDriver to also be generic over T, you'll need to be careful with the constructor signature because it seems you assume you'll always be able to initialize a DefaultClass subtype with just a str argument. Maybe that is what @joel meant with his comment about typing it with Callable. In that case it would look more like this:

from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import Any, Generic, TypeVar


T = TypeVar("T", bound="DefaultClass")


class DefaultClass:
    pass


class Driver(ABC, Generic[T]):
    @abstractmethod
    def driver_call(self, *args: Any, **kwargs: Any) -> T:
        ...


class CustomDriver(Driver[T]):
    def __init__(self, cls_constructor: Callable[[str], T]) -> None:
        self.cls_constructor = cls_constructor

    def driver_call(self, param: str) -> T:
        return self.cls_constructor(param)


class CustomClass(DefaultClass):
    def __init__(self, name: str) -> None:
        self.name = name

Both version work and are type safe with this usage:

cd = CustomDriver(CustomClass)
instance = cd.driver_call("some_string")

A class is also a callable returning an instance of itself, which is why the Callable[..., T] notation is technically more general than type[T].

As you noticed, there are a lot of assumptions here. I hope you see that you need to be clearer in your questions, so that others need to do less guessing about what it is you actually want. If you care to elaborate further and provide more context in your original post, I can try and amend my answer accordingly.

Upvotes: 1

Related Questions