Repository navigation
mypy 1.16 hates PEP 747 typing.TypeForm and thus @beartype #19227
Description
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 = trueWhen 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.
Activity
hauntsaninja commentedon 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 likeerror: Module "typing_extensions" has no attribute "TypeForm". If you ignored that error then you would getAnyThe 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 commentedon Jun 4, 2025
If David's PR isn't ready yet, I'd be happy to take a PR that special cases
TypeFormand treats it like a type alias to Any or maybe objecthauntsaninja commentedon Jun 4, 2025
If you need an emergency workaround, or this affects your users, the following might work:
[-][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[/+]leycec commentedon 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.TypeFormhints. 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
mypyscrutiny. 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. 🙈`mypy` restoration x 1.
davidfstr commentedon Jun 6, 2025
This just means that the
typing.TypeFormwon't be available until Python 3.15, but thetyping_extensionsversion should work whenever typecheckers finish adding support for TypeForm, which is likely to be a long time before Python 3.15 is released.Also a TIL for me, which is funny because I work on mypy!
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 commentedon Jun 6, 2025
Oh, ho. Indeed, this is sweet music. PEP 747 currently reads "Status: Draft." I wasn't quite sure whether that meant that
TypeFormwas 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
TypeFormat runtime. Interestingly, type-checking the unsubscriptedTypeFormis trivial; @beartype already knows what valid type hints are. It's type-checkingTypeFormsubscriptions likeTypeForm[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
foosatisfy the subscriptionTypeForm[bar]?": e.g.,...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.,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 considersAnyto be a subhint ofstr | 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:
Literal[None]is described by the 1-set{None,}.str | Noneis described by the countably infinite set containing theNonesingleton and all possible strings.Anyis described by the countably infinite set containing all possible Python objects.Clearly, the set describing
Anyhas larger cardinality than the set describingstr | None. It can be shown that the type hintAnyis the strict superset of all type hints. No type hint is larger thanAny. All type hints are smaller thanAny. For any type hintfoo, it follows that: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 commentedon Jun 6, 2025
Stringified type hints are just plain hard.
trycasttries to support them only on a best effort basis, and requires using eval. Yuck.Anyis weird. It's both at the "top" and at the "bottom" of the type system. Anything can fit into anAnyandAnycan fit into anything itself. (See §"Any" in the Python typing specification.)objectis much more sensible: Anything can fit into anobject, butobjectis so big (in a set theoretic sense) than it only fits into itself.erictraut commentedon Jun 6, 2025
Anyis 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 aboutAny. The typing spec does a good job of explaining the meaning ofAny. The tldr is thatAnyis a stand-in for "some type that is statically unknown that could satisfy assignability rules". That meansAnyis assignable tostr | Nonebecause there exist types in the type system that are so assignable. Likewise,str | Noneis assignable toAny. Butset[str | None]is not assignable tolist[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
Anyappears in a type, then it's no longer technically correct to talk about "subtyping" (although I admit that I sometimes make that mistake).leycec commentedon Jun 6, 2025
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
Anywas defined as "some type that is statically unknown that could satisfy assignability rules." I'd just always conflated it withobject, 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 inAnyshould 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 funTruly, 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. 😅
`is_subhint(Any, T) is True` for all `T`.