Skip to content

mypy 1.16 hates PEP 747 typing.TypeForm and thus @beartype #19227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
leycec opened this issue Jun 4, 2025 · 9 comments
Closed

mypy 1.16 hates PEP 747 typing.TypeForm and thus @beartype #19227

leycec opened this issue Jun 4, 2025 · 9 comments
Labels
bug mypy got something wrong

Comments

@leycec
Copy link

leycec commented Jun 4, 2025

Bug Report

Mypy began publicly breaking PEP 747 support in @beartype four days ago. Unfortunately for both mypy and @beartype, @beartype leverages PEP 747 literally everywhere. Hundreds upon hundreds of lines of labyrinthine spaghetti code across the @beartype codebase have already been annotated with typing_extensions.TypeForm. Breaking PEP 747 thus breaks the entirety of @beartype (with respect to mypy-driven static type-checking, anyway).

Mypy now emits 169 false positives against the @beartype codebase – all duplicates on the same tired theme of:

beartype/_data/hint/datahintpep.py:136: error: Variable "typing_extensions.TypeForm" is not valid as a type  [valid-type]
beartype/_data/hint/datahintpep.py:136: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases

Moreover, mypy_primer tests against @beartype! Since GitHub Actions-based continuous integration workflows on this repository leverage mypy_primer, PRs on this repo like #18690 are now littered with red badness suggestive of critical failure. Red means bad.

Interestingly, pyright has shipped experimental support for PEP 747 via:

# In "pyproject.toml":
[tool.pyright]
enableExperimentalFeatures = true

When enabled in this way, pyright accepts @beartype without complaint. Pretty sure pyright has supported PEP 747 for... wait. Has it really been over a year now? Oh, Gods. No! My time just up and dilated again. 😅

I'm at a bit of a loss as to what to do. The false positives are pretty intense. I should probably pin the @beartype test suite to mypy < 1.16.0 for the time being. Instead, I've taken the easy way out. I've temporarily disabled mypy integration in our test suite. That doesn't particularly help mypy_primer, but at least @beartype CI now passes. And isn't that what truly matters?

This issue fills me with deep regret. I've come to love and adore mypy. Mypy keeps @beartype honest. Sadly, @beartype no longer knows how to satisfy mypy's unbidden desires.

Advice Nobody Wanted or Asked For

This is the section where @leycec says stuff nobody wants to hear.

pyright has the right approach here. I note that mypy does have a comparable --enable-incomplete-feature command-line option. That option doesn't appear to support PEP 747 at the moment, though. That's a shame. @davidfstr's preliminary PR supporting PEP 747 at #18690 cleverly adds --enable-incomplete-feature=TypeForm, which would then have allowed @beartype to continue satisfying mypy.

Tragically, mypy 1.16.0 shipped without that PR. #18690 may not have been perfect, but the perfect is the enemy of the good. That PR was probably good enough for --enable-incomplete-feature=TypeForm. Nobody except @beartype was going to enable that option. Instead, the current approach of rejecting PEP 747 altogether is the enemy of the good. Something is better than nothing.

mypy 1.16.0 leaves @beartype with nothing. This is why bears cry. 😿 <-- pretend this is a bear

To Reproduce

mypy_primer. Pretty sure that's always the answer.

Allow me to now publicly thank and congratulate @hauntsaninja both for mypy_primer and for including @beartype in mypy_primer. Python Gods above! What a wonderful regression tester that is. 🥰

Expected Behavior

Mypy used to just superficially ignore PEP 747. While (of course) non-ideal, that was still a lot better than blowing hot chunks everywhere. If --enable-incomplete-feature=TypeForm support isn't quite ready yet, perhaps mypy could simply revert back to superficially ignoring PEP 747?

Sometimes, the old times really were the good times.

Actual Behavior

169 errors on the theme of:

beartype/_data/hint/datahintpep.py:136: error: Variable "typing_extensions.TypeForm" is not valid as a type  [valid-type]
beartype/_data/hint/datahintpep.py:136: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases

This depresses me. I'm getting teary-eyed just thinking about all this.

Your Environment

  • Mypy version used: 1.16.0
  • Mypy command-line flags: None
  • Mypy configuration options from mypy.ini (and other config files):
[mypy]
files = beartype/
no_implicit_reexport = True
show_error_codes = True

[mypy-click.*]
ignore_missing_imports = True

[mypy-importlib.metadata.*]
ignore_missing_imports = True

[mypy-numpy.*]
ignore_missing_imports = True

[mypy-pkg_resources.*]
ignore_missing_imports = True
  • Python version used: 3.9—3.13

People Interested

I now summon everyone who cares about PEP 747 and then some! My home boys @davidfstr, @JelleZijlstra, @JukkaL, and @erictraut are the main gotos. Please help solve @beartype's many problems.

@hauntsaninja
Copy link
Collaborator

hauntsaninja commented Jun 4, 2025

This is actually due to a typeshed change (mypy has never had any support for TypeForm and hasn't changed anything, nor is the PEP accepted so there isn't really any guarantee of behaviour to the extent of blocking releases)

The copy of typeshed in mypy 1.15 simply didn't have TypeForm in its stubs.

So when you did from typing_extensions import TypeForm, you'd get an error like error: Module "typing_extensions" has no attribute "TypeForm". If you ignored that error then you would get Any

The copy of typeshed in mypy 1.16 added a TypeForm symbol:

So now mypy resolves the symbol to something (so you no longer get the attribute not found), but then it doesn't treat it specially.

@hauntsaninja
Copy link
Collaborator

If David's PR isn't ready yet, I'd be happy to take a PR that special cases TypeForm and treats it like a type alias to Any or maybe object

@hauntsaninja
Copy link
Collaborator

hauntsaninja commented Jun 4, 2025

If you need an emergency workaround, or this affects your users, the following might work:

# beartype.special_bear_types
from typing import *
MYPY = False
if MYPY:
    TypeForm = Any
else:
    from typing_extensions import TypeForm as TypeForm
# Now use `from beartype.special_bear_types import TypeForm`

@ilevkivskyi ilevkivskyi changed the title [1.16 regression] mypy 1.16 hates PEP 747 typing.TypeForm and thus @beartype mypy 1.16 hates PEP 747 typing.TypeForm and thus @beartype Jun 4, 2025
@leycec
Copy link
Author

leycec commented Jun 5, 2025

...gulp. Indeed, I see that PEP 747 has now been deferred to Python 3.15. That's probably the right move. Without mypy support in time for Python 3.14, it's not quite ready for "prime-time." Thanks so much for the clarification, @hauntsaninja. You're breathtaking. Also, what a username.

I should admit that I'm doing this entirely at the behest of @davidfstr, who authored a @beartype PR annotating the entire @beartype codebase with PEP 747 typing_extensions.TypeForm hints. At the time, that seemed like a reasonable idea – especially because I wasn't doing the work. If I recall correctly, ...I probably don't PEP 747 was originally slated for release with Python 3.14. Since that was only a few months out, I thought I was doing a good thing. Truly, this is a teaching moment. The road to QA Hell is paved with good intentions.

That PR no longer seems like a reasonable idea. @beartype users definitely expect @beartype to pass mypy scrutiny. As much as I like PEP 747, I like my userbase more. I'll roll out your clever hack immediately.

Today I learned about MYPY. That's wondrous black magic. That also means this issue no longer has a reason to live. Let us quietly close this and pretend we saw nothing. 🙈

@leycec leycec closed this as completed Jun 5, 2025
leycec added a commit to beartype/beartype that referenced this issue Jun 5, 2025
This commit is the first (and hopefully last) in a commit chain
permonantly restoring `mypy`-based static type-checking when run both
locally under either `pytest` or `tox` *or* remotely under GitHub
Actions-hosted continuous integration (CI) workflows. Specifically, this
commit resolves upstream issue python/mypy#19227 by employing a clever
hack perpetrated by the glorious mypy maintainer @hauntsaninja
(Shantanu). (*Untimely temerity, ultimately!*)
@davidfstr
Copy link
Contributor

PEP 747 has now been deferred to Python 3.15

This just means that the typing.TypeForm won't be available until Python 3.15, but the typing_extensions version should work whenever typecheckers finish adding support for TypeForm, which is likely to be a long time before Python 3.15 is released.

Today I learned about MYPY. That's wondrous black magic.

Also a TIL for me, which is funny because I work on mypy!

I'll roll out your clever hack immediately.

And I'll try to remember to temporarily undo the hack when I retest mypy's TypeForm support against beartype again. 😉

@leycec I'm glad you got unblocked! Now I just need to find enough bandwidth to finish getting TypeForm support into mypy development branch. Probably a few weeks out.

@leycec
Copy link
Author

leycec commented Jun 6, 2025

the typing_extensions version should work whenever typecheckers finish adding support for TypeForm

Oh, ho. Indeed, this is sweet music. PEP 747 currently reads "Status: Draft." I wasn't quite sure whether that meant that TypeForm was likely to undergo significant revision or be accepted (mostly) as is.

If the latter, this is good. I now understand that @beartype should support this monolith of human ingenuity as soon as feasible. Indeed, users now want @beartype to type-check TypeForm at runtime. Interestingly, type-checking the unsubscripted TypeForm is trivial; @beartype already knows what valid type hints are. It's type-checking TypeForm subscriptions like TypeForm[str | None] that's super-hard...

...or maybe not.

Now that I read PEP 747 closer, it looks like the static type-checking decision problem of "Does the user-defined type hint foo satisfy the subscription TypeForm[bar]?": e.g.,

ok1: TypeForm[str | None] = str | None  # OK
ok2: TypeForm[str | None] = str   # OK
ok3: TypeForm[str | None] = None  # OK
ok4: TypeForm[str | None] = Literal[None]  # OK
ok5: TypeForm[str | None] = Optional[str]  # OK
ok6: TypeForm[str | None] = "str | None"  # OK
ok7: TypeForm[str | None] = Any  # OK

...is equivalent to the runtime type-checking decision problem of subhint relations as implemented by the beartype.door.is_subhint(foo, bar) function: e.g.,

>>> from beartype.door import is_subhint
>>> from typing import Any, Literal, Optional

>>> is_subhint(str | None, str | None)
True  # OK
>>> is_subhint(str, str | None)
True  # OK
>>> is_subhint(None, str | None)
True  # OK
>>> is_subhint(Literal[None], str | None)
True  # OK
>>> is_subhint(Optional[str], str | None)
True  # OK
>>> is_subhint("str | None", str | None)
beartype.roar.BeartypeDoorNonpepException: Type hint 'str | None' currently
unsupported by "beartype.door.TypeHint".  # OHNO
>>> is_subhint(Any, str | None)
False  # OHNOES

SO. CLOSE. Man. @beartype almost got to the finish line. is_subhint() fell down on the job for stringified type hints. Nobody ever complained about that. Thus, nothing was ever done for that.

As for is_subhint(Any, str | None), though... @beartype's approach looks right. It's kinda weird that PEP 747 considers Any to be a subhint of str | None.

Consider set theory. Specifically, consider a type hint to be uniquely described by the set of all objects satisfied by that hint. Under this conception:

  • The type hint Literal[None] is described by the 1-set {None,}.
  • The type hint str | None is described by the countably infinite set containing the None singleton and all possible strings.
  • The type hint Any is described by the countably infinite set containing all possible Python objects.

Clearly, the set describing Any has larger cardinality than the set describing str | None. It can be shown that the type hint Any is the strict superset of all type hints. No type hint is larger than Any. All type hints are smaller than Any. For any type hint foo, it follows that:

# Any type hint "foo" should be a subhint of "Any".
>>> is_subhint(foo, Any)
True  # <-- this should always be the case

# "Any" should *NEVER* be a subhint of any type hint "foo".
>>> is_subhint(Any, foo)
False  # <-- this should always be the case, too

After all, how could any type hint describe a set larger than Any – the type hint described by a countably infinite set containing all possible Python objects? Pretty much impossible, right?

I stroke my chin thoughtfully and squint my eyes.


@leycec, in his dreams

@davidfstr
Copy link
Contributor

is_subhint() fell down on the job for stringified type hints. Nobody ever complained about that. Thus, nothing was ever done for that.

Stringified type hints are just plain hard. trycast tries to support them only on a best effort basis, and requires using eval. Yuck.

It's kinda weird that PEP 747 considers Any to be a subhint of str | None.

Any is weird. It's both at the "top" and at the "bottom" of the type system. Anything can fit into an Any and Any can fit into anything itself. (See §"Any" in the Python typing specification.)

object is much more sensible: Anything can fit into an object, but object is so big (in a set theoretic sense) than it only fits into itself.

@erictraut
Copy link

Any is weird. It's both at the "top" and at the "bottom" of the type system.

Any is indeed special, but it can be misleading to think of it as "at the 'top' and 'bottom' of the type system". It's also problematic to think of it in terms of set theory or subtyping because those concepts break down when talking about Any. The typing spec does a good job of explaining the meaning of Any. The tldr is that Any is a stand-in for "some type that is statically unknown that could satisfy assignability rules". That means Any is assignable to str | None because there exist types in the type system that are so assignable. Likewise, str | None is assignable to Any. But set[str | None] is not assignable to list[Any] because there are no types in the type system that would make this assignment legal.

I'll also note that the term "subhint" is not defined in the typing spec, and I don't think I've seen it used before. The term subtype is defined by the typing spec, but it applies only to fully-static types. As soon as an Any appears in a type, then it's no longer technically correct to talk about "subtyping" (although I admit that I sometimes make that mistake).

@leycec
Copy link
Author

leycec commented Jun 6, 2025

I'll also note that the term "subhint" is not defined in the typing spec...

Indeed, @beartype invented the subhint relation. It's a well-defined partial ordering over the universe of type hints. This partial ordering enables type hints to be richly compared via the standard comparison operators, which then enables developers to implement novel typing algorithms by simply "porting" existing algorithms that require a partial ordering like topology sort. Sorting type hints then enables developers to implement even more novel typing algorithms on top of efficiently sorted collections of type hints.

Pragmatically, the subhint relation is also the theoretical basis for O(1) linear-time pure-Python multiple dispatch implemented by @wesselb's Plum. Without that, efficient general-purpose multiple dispatch is basically infeasible. Glory be to Plum.

I'm confident that the subhint relation is, in fact, the theoretical basis for PEP 747. I was unaware that Any was defined as "some type that is statically unknown that could satisfy assignability rules." I'd just always conflated it with object, because that was the lazy and thus easy approach. But... your explanation totally makes sense, @erictraut. Thanks so much for the deep dive.

Repairing beartype.door.is_subhint() to correctly handle this nuance in Any should be trivial. Once that's done, is_subhint() and PEP 747 will be in perfect alignment! Truly, a great QA day is upon us.

tl;dr: subhint relation == massive typing fun

and I don't think I've seen it used before.

Truly, there is a first time for everything. Today, I learned how to make tofu at home. The texture was disgusting but the taste was exquisite. Someday, I will make tofu that is worthy of the name of Japan. 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug mypy got something wrong
Projects
None yet
Development

No branches or pull requests

4 participants