Current state of specification
-
Subclassing of Any at runtime was accepted in 3.11. The motivating example at the time was unittest.Mock, and only showed what happens with single inheritance. It was always possible in stubs.
-
Type checkers treat untyped library code as if it were Any as part of gradual typing. This behavior predates subclassing of Any itself and exists due to gradual typing.
-
Subclassing of Any is specifically documented in the specification as existing for when things are more dynamic than the type system can express. I cannot find it as documented or specified for the behavior of untyped imports, but I don’t think untyped imports as Any is currently a point of controversy.
A problem of clarity
Any is documented as being consistent with all possible types. It is not specified what that actually means when present as a base class either due to untyped imports or due to 3.11+ behavior of directly subclassing Any. There are multiple reasonable interpretations of the possible behavior.
Illustrating the problem
The bodies of methods of functions used here will be left without an implementation when unnecessary to illustration.
from untyped import Unknown # standing for an untyped library
class ExampleA(untyped.Unknown):
def foo(self) -> int:
...
def only_option(a: ExampleA):
a.foo() # int
a.foo("something") # error
a.bar() # Any
This case is relatively simple. While not explicitly specified, it directly matches the motivating case for runtime subclassing of typing.Any
and there is only 1 possible interpretation
from untyped import Unknown # standing for an untyped library
class SerialMixin:
def serialize(self) -> bytes:
...
class EasyExample(SerialMixin, Unknown):
def foo(self) -> int:
...
class AmbiguousExample(Unknown, SerialMixin):
def foo(self) -> int:
...
def easy(a: EasyExample):
a.foo() # int
a.serialize() # bytes
a.serialize(byte_order="le") # error
a.serialize(web_safe_str=True) # error
a.bar() # Any
def unspecified(a: AmbiguousExample):
a.foo() # int
a.serialize() # interpretation dependent
a.serialize(byte_order="le") # interpretation dependent
a.serialize(web_safe_str=True) # interpretation dependent
a.bar() # Any
The body of unspecified
shows the problem cases and how they differ due to ambiguity in one potential MRO layout.
For the first case of interpretation-dependent behavior, Is a type documented as consistent with all types allowed to have to consider that some of those types are inconsistent with others, or do we assume that all uses of Any with this pattern must not violate LSP?
If Any must not violate LSP, then a.serialize()
there is bytes
is Any may violate LSP, then a.serialize()
is Any
The current behavior of both mypy and pyright of this example is to determine this as bytes
I believe assuming consistency here to be correct, but nothing is preventing this from having a different definition at runtime that, for instance, serialized to a string.
If we decide it can violate LSP, we don’t need to look at the latter example, it should also be Any
If we decide it must be consistent with the other bases, the second and third usages labeled interpretation dependent need consideration.
There are possible types that could be substituted in place of Unknown
/ Any
here which remain consistent with all typed code and for which these could not error.
if the untyped code has this “shape”
class Unknown:
@overload
def serialize(self) -> bytes: ...
@overload
def serialize(self, *,
web_safe_str: Literal[True], byte_order: Literal["le", "be"]) -> NoReturn: ...
@overload
def serialize(self, *, web_safe_str: Literal[True]) -> str: ...
@overload
def serialize(self, *, byte_order: Literal["le", "be"]) -> bytes: ...
def serialize(self, *,
byte_order: Literal["le", "be"] | None = None, web_safe_str: Literal[True, False] = False,
) -> Any: ...
None of the uses within the prior above function unspecified
are inconsistent with this hypothetical type, and more is allowed.
There are two possible interpretations remaining here if we assume consistency for LSP:
- Type checkers prefer a known definition over a partially unknown one
- Type checkers use the known definition to create a minimum bound
Under interpretation 1, the two cases we still need to consider are each error.
Under interpretation 2, those same cases are each Any
I believe that the current specification calls for interpretation 2, especially when considering the intent of gradual typing not introducing errors for things that are not currently and may not be typable, but I do not think that it is clear enough to say so definitively or that we should not examine this for potential impact and pick a set of consistent rules for the behavior we intend to support within the type system.
Current type checker state
Of the type checkers I’m aware of that have support for Any as part of inheritance at this point in time,
Mypy (online playground) and pyright (online playground) both currently pick the known definition
pyre (online playground) errors when it doesn’t know if something is a valid base currently even when attempting to work around this
Possible resolutions
Assume LSP compatibility, prefer typed code over untyped code
This would keep the behavior that pyright and mypy are using for their users.
It would entail documenting that when something is a subtype of a known and unknown type, that type checkers should prefer the known definitions when available. This has a low incidence of potential False positives that only occur when untyped code is in a diamond pattern with typed code.
This can be accomplished in the following ways:
- specifying that Any is not all possible types in some contexts, and defining alternative behavior for a list of contexts.
- treating untyped imports as something that isn’t Any, perhaps Unknown
, and defining the alternative behavior of that.
Assume LSP compatibility, use ordering within MRO to determine a minimum bound
This would entail specifying that Any as a base class has behavior where it it consistent with all possible types based on where it exists in MRO, more closely matching runtime. This has a high potential False negative rate, limited to cases where people have multiple inheritance involving untyped code.
Edit: Detailed below, this method can also be used to have the behavior of preferring the known minimum bound and not allow the upper bound when more is known
Do not assume LSP compatibility
When Any is ordered in MRO with higher precedence than typed code, it erases the typed code’s known types, since it could have alternative definitions, and untyped code has no guarantee about LSP.
Impact on current code and features
This first of these options with either method of going about it would have no impact on existing code using mypy and pyright. I have not exhaustively checked all type checkers, so there may be impact if other type checkers beyond what were listed above support subclassing of Any, but make a different decision here.
The second of the above options may narrowly cause some code to have false negatives and some other code to no longer have false positives, limited to multiple inheritance involving gradual typing, where the gradually typed code is listed by the affected code as having priority in MRO.
The third option would be the most disruptive and have a higher potential incidence of false negatives. It creates issues with the use of isinstance
for narrowing. It is possible to rule out if we determine that Any’s definition of compatibility includes that it cannot be used to violate LSP. I do not view this as a viable interpretation due to the impact it would have on existing code, even if it may appear to some as a reasonable interpretation in a vacuum.
Impact on future features
I intend to take whatever resolution we can find in the currently existing case here, to then reexamine the ongoing work on Intersection types to ensure the future feature remains consistent with pre-existing parts of the type system. It is worth noting that multiple typing council members gave persuasive reasoning in that discussion which would only be consistent with the second option. While the reasoning was persuasive, I do not think they should be held to that reasoning here as it was only persuasive within the assumptions we were working under at the time, which included assumptions about subtying of Any.
Edit notes:
- A Note about LSP-compatible Any and a link to a relevant post below was added
- the phrase “top type” which was used lazily on my part was removed and replaced with a more appropriate phrasing.