In this example, can calling m1.__getitem__ ever actually get you into trouble? You can’t pass in a value of type V or anything that would let __getitem__ know it was being called as a LazyMap[object, object] instead, so there’s nothing you can do to make m0.__getitem__ return a value of the wrong type (unless you directly access the private member m1.__cache).
So for example, would it be possible to add a new feature Private[] to be used as follows:
from typing import Callable, Private
class LazyMap[K, V]:
_getter: Private[Callable[[K], V]]
_cache: Private[dict[K, V]]
def __init__(self, getter: Callable[[K], V]) -> None:
self._getter = getter
self._cache = {}
@Private
def _update_cache(self, key: K, value: V) -> None:
self._cache[key] = value # "Safe" use of private member bypassing variance
def __getitem__(self, key: K, /) -> V:
try:
value = self._cache[key]
except KeyError:
value = self._getter(key)
self._update_cache(key, value) # Safe call to private method
return value
m0: LazyMap[object, int] = LazyMap(id)
m1: LazyMap[object, object] = m0
m1['foo'] # Safe: returns an int, which is a valid object
m1._cache['bar'] = object() # Unsafe: accessing private member
m1._update_cache('bar', object()) # Unsafe: calling private method
This would allow safe type checking for both library authors and library users without any hacks or workarounds, and would solve the problem in this thread of being able to correctly infer variance (private members would be ignored when calculating variance). It would also let you actually validate your assumptions that your type can be used safely with the variance you want - if you just force a particular variance with a TypeVar and then # type: ignore any associated type warnings, you have to rely on hoping you reasoned about it correctly. But with Private the type checker can actually check these assumptions for you.
The only final question is - is this actually safe? Or is there still some way you can break m0 by accessing its public interface through m1? I think there’s actually bug in both mypy and pyright, because the way I thought you might try breaking this is by adding:
def update_getter(self, getter: Callable[[K], V]) -> None:
self._getter = getter
Then you could do m1.update_getter(lambda x: x) to break m0. But I think the presence of this method should actually force V to be invariant (well, contravariant - but invariant when combined with the other definitions). To see why, try this class:
from typing import Generic, TypeVar
T_co = TypeVar('T_co', covariant=True)
class Wrapper(Generic[T_co]):
def __init__(self, value: T_co):
self._value = value
def get(self) -> T_co:
return self._value
#def set(self, value: T_co) -> None:
# self._value = value
def set_from(self, fn: Callable[[], T_co]) -> None:
self._value = fn()
def f(w: Wrapper[object]):
reveal_type(w.get())
#w.set('foo')
w.set_from(lambda: 'foo')
x: Wrapper[int] = Wrapper(1)
f(x)
print(x.get())
Both mypy and pyright report no errors here, despite the fact that x.get() returns the string 'foo'. However if you uncomment the Wrapper.set() function, they both complain about T_co being used non-covariantly.