Design pattern for combining separate branches into common structure

Viewed 123

I've got applications that consist of a data loader and a data transformer. Each loader and each transformer are subclasses of an abstract base loader and an abstract base transformer, which I'll omit in the example below. There is a 1:1 mapping between the concrete loaders and transformers, i.e. is known which loader and transformer belong together.

Say we have two loaders and two transformers, handling data

class Data1: ...

class Data2: ...


class Loader1:
    def get_data(self) -> Data1: ...

class Loader2:
    def get_data(self) -> Data2: ...


class Transformer1:
    def transform_data(self, data: Data1) -> None: ...

class Transformer2:
    def transform_data(self, data: Data2) -> None: ...

These classes could now be combined into applications

class App1:
    Loader = Loader1
    Transformer = Transformer1

class App2:
    Loader = Loader2
    Transformer = Transformer2

with an accompanying factory

from typing import Union, Type

def make_app(use_app1: bool) -> Union[Type[App1], Type[App2]]:
    if use_app1:
        return App1
    else:
        return App2

This is how I'd like to use the above

def main(use_app1: bool) -> None:
    app = make_app(use_app1)
    loader = app.Loader()
    data = loader.get_data()
    transformer = app.Transformer()
    transformer.transform_data(data=data)

However, mypy complains:

error: Argument "data" to "transform_data" of "Transformer1" has incompatible type "Union[Data1, Data2]"; expected "Data1"  [arg-type]
error: Argument "data" to "transform_data" of "Transformer2" has incompatible type "Union[Data1, Data2]"; expected "Data2"  [arg-type]

Is there some way to convince mypy that the branches Loader1 -> Data1 -> Transformer1 and Loader2 -> Data2 -> Transformer2 are separate and will not be mixed?

Is there an alternative pattern that could be used for this use case?

3 Answers

Here's a different answer that still involves a fair amount of extra code, but at least makes a little more sense than adding a pointless if-else statement in there just for the type-checker. This solution uses generic abstract base classes to make it explicit to the type-checker that Data1 and Data2 have the same interface, etc. etc.:

from abc import abstractmethod, ABCMeta
from typing import Union, Type, TypeVar, Any, Protocol


### ABSTRACT INTERFACES ###


class AbstractData:
    __slots__ = ()
    

class AbstractLoader(metaclass=ABCMeta):
    __slots__ = ()
    
    @abstractmethod
    def get_data(self) -> AbstractData: ...
    

D = TypeVar('D', bound=AbstractData, contravariant=True)
    

class AbstractTransformer(Protocol[D]):
    __slots__ = ()
    
    @abstractmethod
    def transform_data(self, data: D) -> None: ...


L = TypeVar('L', bound=AbstractLoader, covariant=True)
T = TypeVar('T', bound=AbstractTransformer[Any], covariant=True)


class AbstractApp(Protocol[L, T]):
    __slots__ = ()
    
    @classmethod
    @property
    @abstractmethod
    def Loader(cls) -> Type[L]: ...
    
    @classmethod
    @property
    @abstractmethod
    def Transformer(cls) -> Type[T]: ...
    

### CONCRETE IMPLEMENTATIONS ###
    

class Data1(AbstractData): ...


class Data2(AbstractData): ...


class Loader1(AbstractLoader):
    def get_data(self) -> Data1: ...


class Loader2(AbstractLoader):
    def get_data(self) -> Data2: ...


class Transformer1(AbstractTransformer[Data1]):
    def transform_data(self, data: Data1) -> None: ...


class Transformer2(AbstractTransformer[Data2]):
    def transform_data(self, data: Data2) -> None: ...


class App1(AbstractApp[Loader1, Transformer1]):
    Loader = Loader1
    Transformer = Transformer1


class App2(AbstractApp[Loader2, Transformer2]):
    Loader = Loader2
    Transformer = Transformer2
    

def make_app(use_app1: bool) -> Type[AbstractApp[Any, Any]]:
    if use_app1:
        return App1
    else:
        return App2


def main(use_app1: bool) -> None:
    app = make_app(use_app1)
    loader = app.Loader()
    data = loader.get_data()
    transformer = app.Transformer()
    transformer.transform_data(data=data)

Okay, here's a third attempt at solving this. In this attempt, I'm using abstract protocols to tell MyPy that, in fact, in a lot of these functions, it doesn't matter what specific type is being returned, as long as the object being returned has a certain interface.

from typing import Type, Protocol, cast


### ABSTRACT INTERFACES ###


class DataProto(Protocol): ...
    

class LoaderProto(Protocol):
    def get_data(self) -> DataProto: ...
    

class TransformerProto(Protocol):
    def transform_data(self, data: DataProto) -> None: ...


class AppProto(Protocol):
    Loader: Type[LoaderProto]
    Transformer: Type[TransformerProto]
    

### CONCRETE IMPLEMENTATIONS ###
    

class Data1: ...


class Data2: ...


class Loader1:
    def get_data(self) -> Data1: ...


class Loader2:
    def get_data(self) -> Data2: ...


class Transformer1:
    def transform_data(self, data: Data1) -> None: ...


class Transformer2:
    def transform_data(self, data: Data2) -> None: ...


class App1:
    Loader = Loader1
    Transformer = Transformer1


class App2:
    Loader = Loader2
    Transformer = Transformer2
    

GenericAppClassType = Type[AppProto]
    

def make_app(use_app1: bool) -> GenericAppClassType:
    if use_app1:
        return cast(GenericAppClassType, App1)
    else:
        return cast(GenericAppClassType, App2)


def main(use_app1: bool) -> None:
    app = make_app(use_app1)
    loader = app.Loader()
    data = loader.get_data()
    transformer = app.Transformer()
    transformer.transform_data(data=data)

Try it out on mypy playground here.

The issue here is that there are in fact two different signatures for your make_app function: one signature if use_app1 is True, and another if use_app1 is False. typing.overload, in combination with typing.Literal, is the solution here, as @overload allows us to register multiple different signatures of a function. Implementations of a function decorated with @overload are ignored at runtime — they are solely for the type checker — so the body of these functions can be left empty. Generally, you'll just put a literal ellipsis ... or a docstring in the body of these functions. There must be at least one concrete implementation of the function not decorated with @overload, for use at runtime.

from typing import overload, Literal, Union, Type

App1Type = Type[App1]
App2Type = Type[App2]

@overload
def make_app(use_app1: Literal[True]) -> App1Type:
    """Signature of the function when `use_app1` is True"""

@overload
def make_app(use_app1: Literal[False]) -> App2Type:
    """Signature of the function when `use_app1` is False"""

def make_app(use_app1: bool) -> Union[App1Type, App2Type]:
    """Concrete implementation of the function, for use at runtime"""

    if use_app1:
        return App1
    else:
        return App2

The documentation for typing.overload is here; the documentation for typing.Literal is here.


EDIT: Looks like this doesn't work. You can make it work by rewriting the main function like this, but there must be a better way out there than doing a truly pointless if-else statement...

def main(use_app1: Literal[True, False]) -> None:
    app: Union[App1Type, App2Type]
    loader: Union[Loader1, Loader2]
    data: Union[Data1, Data2]
    transformer: Union[Transformer1, Transformer2]
    
    if use_app1:
        app = make_app(use_app1)
        loader = app.Loader()
        data = loader.get_data()
        transformer = app.Transformer()
        transformer.transform_data(data=data)
    else:
        app = make_app(use_app1)
        loader = app.Loader()
        data = loader.get_data()
        transformer = app.Transformer()
        transformer.transform_data(data=data)
Related