pythonpython-dataclasses

How can I define a protocol for a class that contains a protocol field and use a dataclass implementer?


I want to do this:

import typing
import dataclasses

class ConfigProtocol(typing.Protocol):
    a: str

@dataclasses.dataclass
class Config:
    a: str

class HasConfigProtocol(typing.Protocol):
    config: ConfigProtocol


@dataclasses.dataclass
class HasConfig:
    config: Config

def accepts_hasconfig(h: HasConfigProtocol):
    pass

accepts_hasconfig(HasConfig(config=Config(a="")))

But in pylance it reports:

Argument of type "Position" cannot be assigned to parameter "template_position" of type "PositionProtocol" in function "__init__"
  "Position" is incompatible with protocol "PositionProtocol"
    "config" is invariant because it is mutable
    "config" is an incompatible type
      "Configuration" is incompatible with protocol "ConfigurationProtocol"

To get this to work do I need to freeze my dataclasses and provide read only access in my protocols like so?

class ConfigProtocol(typing.Protocol):
    @property
    def a(self) -> str:
        ...

@dataclasses.dataclass(frozen=True)
class Config:
    a: str

class HasConfigProtocol(typing.Protocol):
    @property
    def config(self) -> ConfigProtocol:
        ...


@dataclasses.dataclass(frozen=True)
class HasConfig:
    config: Config

def accepts_hasconfig(h: HasConfigProtocol):
    pass

accepts_hasconfig(HasConfig(config=Config(a="")))

Long term this is the code in one module, module_a which is stand alone. There is another module_b which also exists, has similar code and is stand alone. I want to use instances of HasConfig from module_a in module_b. Because the modules do not know about eachother, I am including the same protocol definition in each of them.

To reuse protocols across modules and have instances work for both of them, the protocol definitions must be the same, and the Protocol definitions only must use @property definitions.


Solution

  • The issue is that HasConfig class accepts Config and not the ConfigProtocol for attribute config. This makes HasConfig incompatible with HasConfigProtocol, if and only if HasConfigProtocol.config is mutable.

    The reason for this is if the config attribute is mutable, then someone working with HasConfigProtocol could expect to set the config attribute to anything that conforms to the ConfigProtocol and not just Config. This other class will conform to the ConfigProtcol interface, but it might not conform to the interface of Config, and so errors could be introduced. Given your example only has one attribute it is hard to see what the issue is. Perhaps a worked example with multiple attributes could better indicate the pitfalls that the type checker is trying to save you from.

    class FooP(Protocol):
        number: int
    
    @dataclass
    class Foo:
        number: int
        other_attr: float
    
    @dataclass
    class AnotherFooImpl:
        number: int
        some_ints: list[int]
    
    class BarP(Protocol):
        foo: FooP
    
    @dataclass
    class Bar:
        foo: Foo
    
    bar: Bar = Bar(foo=Foo(number=0, other_attr=1.0))
    assert bar.foo.other_attr == 1.0
    bar_p: BarP = bar
    bar_p.foo = AnotherFooImpl(0, [])  # valid according to BarP, but not to
                                       # according to Bar
    assert bar.foo.other_attr == 1.0   # AttributeError! AnotherFooImpl has no
                                       # attribute called other_attr
    

    The fix for the static type checker is simple. Change your HasConfig implementation such that is accepts ConfigProtocol instead of Config. ie.

    @dataclasses.dataclass
    class HasConfig:
        config: ConfigProtocol
    #                 ^^^^^^^^-- new bit