Conflicting tuple base classes

This came up for platform.uname_result which is a bit hacky. On 3.9+ it pretends to be a six-field named tuple, but one of the fields is lazily evaluated and not included in the constructor.

In this typeshed MR, I attempted to model this behavior like this:

class _uname_result_base(NamedTuple):
    system: str
    node: str
    release: str
    version: str
    machine: str

class uname_result(_uname_result_base, tuple[str, str, str, str, str, str]):  # type: ignore[misc]  # pyright: ignore[reportGeneralTypeIssues]
    @property
    def processor(self) -> str: ...

Mypy has the special case error message Class has two incompatible bases derived from tuple, while pyright gives the slightly more generic Base classes of uname_result are mutually incompatible.

Once the proper ignores are placed on it, the more significant divergence is that mypy follows the tuple[str, str, str, str, str, str] base class and allows access to the sixth field, while pyright gives Index 5 is out of range for type uname_result.

I can find a different solution, but mypy’s behavior in this case seems useful. I brought the issue here to see if any consensus might exist about that.

Does this need to be typed as a NamedTuple? I think you can get around the issues here by typing it as a protocol that describes the structure of the type the user receiving this can rely on since it isn’t meant to be a user-constructed type.

4 Likes

I don’t think we should be standardizing what happens with a type ignore. Anything written with a type ignore is already outside of the type system for a reason. I like what Michael mentioned above with the protocol here. You can also inherit from tuple[str, ...] and add properties

class uname_result(tuple[str, ...]):
    @property
    def system(self) -> str: ...
    @property
    def node(self) -> str: ...
    @property
    def release(self) -> str: ...
    @property
    def version(self) -> str: ...
    @property
    def machine(self) -> str: ...
    @property
    def processor(self) -> str: ...

You can add a constructor without a type ignore if it is important to be user constructed this way. This method is able to retain that it is a tuple of strings if that’s important for existing code.

You could even make this act as a 6-tuple if you type the constructor adequately to cover it, it’s only making it a NamedTuple that fails because those have a predefined construction.

It’s better to type accurately with some information loss than add a type ignore. It’s a difference between creating unsoundness and leaving what can’t be expressed about something out of what is declared for type-safe use.