Changing runtime behavior of types.UnionType to respect order in equality and hashing

Currently, at runtime, int | str == str | int, and both unions have the same hash.

This leads to some problems, mainly, typing.py implements a cache of “genericaliases” it sees at runtime. A list[int | str] is treated the same as a list[str | int]. Thus the cache currently just returns anything that it sees with a matching union, without respect for the order. Thus typing.py erases order information.

For static type checkers, this isn’t a problem. However, runtime type checkers use this information. Quoting from Samuel Colvin’s (the author of pydantic) comment here https://github.com/python/cpython/pull/103541#issuecomment-1517839464:

  • if you’re using unions to inform data coercion (as pydantic and other libraries do), the order of unions members should logically inform the order in which you try to coerce data types.
  • Similarly, if you’re using unions for documentation (one of type hints other prime uses, and one that PEP 649 has bent over backwards to support), being able to repr the type hint as closely as possible to the code is important.

Now, this issue was more prominent with the old typing.List, but not so much with list as the new generic aliases have no caching implemented (yet). We’re now considering a cache for list (types.GenericAlias), this issue now needs to be re-considered, or we risk degrading the experience of runtime type checkers.

We have two options:

  • Change runtime equality of types.UnionType and thus its hash as well.
  • Leave it be.

I suspect the first option requires writing a PEP, as it is backwards incompatible and there is some ambiguity as to whether it conflicts with PEP 604.

EDIT: WE ARE NO LONGER TAKING THIS APPROACH. SEE THE COMMENT AT:

This post is out of date. Please disregard it.

2 Likes

I’m not disagreeing with writing a PEP (in fact, that’d probably be a good thing), but is it really backwards incompatible? Are there any examples of code that relies on the current behavior, aside from caching? I generally think of caching as a performance optimization and that your logic should not depend on it.

1 Like

The backwards incompatible part is that now list | str != str | int (as we need to change both _eq__ and _hash__). My understanding of CPython’s backward compatiblity policy is that any changes that can be observed in user code are subject to backwards compatbility. That’s what I understand from reading PEP 387 – Backwards Compatibility Policy | peps.python.org. How other people interpret it may differ.

The main question is whether this counts as fixing a bug, or changing intended behaviour. (Not the ONLY question, as sometimes even something that’s clearly a bug has to be maintained through a deprecation process due to unintended dependence - see for example hash randomization, which had the side effect of changing dict iteration order in Python 2.7, and thus was not turned on unless an environment variable specifically requested it.)

Does equality need to change here, or should the iteration order be maintained while still considering list[int|str] to be equal to list[str|int]? Currently, with the caching not happening, that’s the behaviour we see, so it could be argued that folding the two together using the cache is a bug (since they’re distinguishable).

Not enough of a typing user to have a strong opinion though.

I would like to clarify examples posted by @kj0

Right now typing.py indeed has caching, but it has nothing to do with list and | unions.
Right now these two are the same:

>>> import typing
>>> typing.List[typing.Union[str, int]] is typing.List[typing.Union[int, str]]
True

But, right now types.GenericAlias and types.UnionType does not behave the same:

>>> list[int | str] is list[str | int]
False

Obviously it is an intentional behavior. Before changing it, could you please find the previous discussions about typing.Union (in particularly in which it was introduced) and find whether the reasons were explicitly mentioned?

It seems to me that the change would negatively affect caching and may break some uses of singledispatch() and dataclasses. I wonder how it can affect MyPy. The following code can become an error:

def f() -> int | str:
    ...
def g(a: str | int) -> None:
    ...
g(f())

I wonder how it can affect MyPy

As far I know, it won’t affect MyPy.
MyPy uses its own mypy.types.UnionType which is created differently.
Link: mypy/mypy/types.py at 2a4c473aa809f314c489c9f0bde0ae47842a2f96 · python/mypy · GitHub

1 Like

Yes, this should have no impact on purely static type checkers, which make no use of the runtime type

I can see the backwards compatibility risks of making it so str | int != int | str for code that might have been making this assumption. While I think it might be reasonable to argue for this breaking change, I also want to point out that that equality itself isn’t a problem for pydantic, just the caching logic in the generic __class_getitem__s that is influenced by that equality.

Is it definitely necessary that calls to list.__class_getitem__ et al get cached? I saw on the GitHub issue:

Performance and memory testing

I haven’t done any yet. But, I think I slowed things down pretty badly :frowning:
But, as far as I understand this is a price we want to pay for less memory usage.

If I go digging should I expect to find some demonstration of the memory usage benefits somewhere?

If the caching has been demonstrated to provide significant benefits that need to be preserved, has it been considered/rejected to use a modified cache implementation (i.e., not a vanilla lru_cache) so that unions with different item orderings would produce different cache results, while the unions still compare as equal? (E.g., caching by id rather than equality, though I realize something that naive wouldn’t work by itself.) Admittedly it seems like a longshot that a sufficiently efficient alternative would exist, but just want to make sure.

Also, it seems to me the motivation for this cache was at least partially to help reduce the memory footprint of modules that repeat the same annotations many times (e.g. list[str]).

My understanding is that PEP649 is going to be accepted, and if so, would the deferred evaluation of annotations reduce the need/benefits of this caching behavior, since annotations wouldn’t be eagerly evaluated?

Even if it’s a problem to remove the caching behavior of typing.List.__class_getitem__ for any reason, I’m thinking this could be an argument against adding the caching behavior for list.__class_getitem__ etc.

Thanks so much @kj0 for you excellent explanation of the problem, I couldn’t have put it more clearly.

I’m sure that we (the Pydantic team) would be willing to have a go at writing a PEP to change the behaviour so either:

  1. Union equality accounts for member (is “member” the right word for the things inside a union?) order, and so does hashing so - hash(X | Y) != hash(Y | X) and X | Y != Y | X
  2. or, the caching behaviour is changed so we break the rules on __hash__ matching __eq__ - hash(X | Y) != hash(Y | X) but X | Y == Y | X (I’m sure there are other examples of breaking this rule???)
  3. or, we provide some way to get the original type hint without the cache loosing information - all other behaviour could stay the same

If we went with route 1, we could add a utility function to hash the union in a order independent way, to help singledispatch and etc.

I haven’t checked, but I assume that since currently X | Y = Y | X, also X | Y == X | Y | Y - in other word we do the equivalent of frozenset(members) instead of tuple(members)? If this is the case, any PEP would need to decide what we want to do with repeat members - for the runtime use case we would want to deduplicate, but we can do that ourselves, so we don’t much care what the PEP decides. Maybe the documentation use case does care?

To write the PEP we need to:

  • agree on the above
  • have a sponsor - I think that’s necessary to write a PEP?

Last point - If we agree that changing this is a breaking change and needs a PEP and a deprecation period, then surely changing the behaviour of list[X | Y]...list[Y | X] is also a breaking change? (albeit, one that more closely aligns behaviour to the PEP)

I would therefore ask that gh-101859: Add caching of `types.GenericAlias` objects by sobolevn · Pull Request #103541 · python/cpython · GitHub is deferred or cancelled.

@guido suggested that we could preserve order by changing our typing cache to respect order. This way we don’t need backwards incompatible changes or writing a PEP. Users would be saved from a lot of trouble :).

@sobolevn seems to already be working on this in Make sure that typing cache differentiate `Union[int, str]` and `Union[str, int]` · Issue #103749 · python/cpython · GitHub.

I think this issue’s title is now defunct/outdated.

3 Likes

Author of typedload here (kinda like pydantic but older, faster, and can handle unions).

pydantic’s naïve attitude towards handling unions is bad.

Please don’t ask to break everybody’s software just because your module’s implementation does a naïve thing. I’ve implemented unions to prefer avoiding a cast when that is possible. You could have done the same.

The change would break existing software for me. I’m vehemently against it.

You can fix pydantic by ranking the types rather than just doing a loop and returning the 1st cast that works.

Hi, we are no longer taking this approach. As such, nobody’s code should break I don’t think we need to point fingers and blame anyone’s library here.

Speaking as a 3rd party with no involvement in type validators.

There should be no need to know the ordering in a union.

int | float shouldn’t cause 1.1 to become 1 unless it’s redefined as float | int. It’s just a bug.

You cited a specific library in the top post, seems natural to refer to the specific library you cited in replies.

Please read the rest of the posts after the first one. This entire thread is out of date, and there’s no point discussing this now. We are not changing unions.

See this comment. It’s the approach we’re taking.

2 Likes