Repository navigation
Recognize PEP-728 TypedDict class keywords - #28533
Merged
Merged
Conversation
Summary
--
[PEP 728] introduced two new keyword arguments for `TypedDict`s: the boolean `closed` argument and
`extra_items`, which allows specifying the type for any additional entries in the `TypedDict`. See
this example from the PEP:
```py
class Movie(TypedDict, extra_items=bool):
name: str
a: Movie = {"name": "Blade Runner", "novel_adaptation": True} # OK
b: Movie = {
"name": "Blade Runner",
"year": 1982, # Not OK. 'int' is not assignable to 'bool'
}
```
Like some of my previous 3.15 PRs, this PR adds support for visiting the value of the `extra_items`
keyword argument as a type expression, again using `F401` to demonstrate that such quoted
expressions mark their corresponding imports as used. This requires a little bit of special handling
when visiting class statements, which can also be deferred in stub files, but overall wasn't too bad
I think. `closed` doesn't need any special handling in the semantic model since it's just a flag,
not a type expression.
This PR also updates [`convert-typed-dict-functional-to-class` (`UP013`)] to recognize the new
keywords and carry them along in its fix.
The first commit contains the visitor changes, and the second contains the `UP013` update.
Once again, my Codex reviewers found an existing bug that this exposes in a new way. We could
sidestep this, and substantially simplify the code at the same time, by dropping the recursive base
class traversal, but it seemed more useful to me to keep the recursive traversal and fix the other
bug later if needed. Either unhandled case (multiple levels of inheritance in one file, or
inheritance + shadowing + unrelated class with `extra_items`) seems quite niche, though.
<details>
<summary>`any_base_class` bug</summary>
Ancestor lookup can misclassify an ordinary class as a TypedDict β The lookup resolves ancestor
names in the current scope, so an unrelated definition can shadow the actual base:
```py
from typing import TypedDict
class Base:
def __init_subclass__(cls, **kwargs): pass
class Middle(Base): pass
class Namespace:
class Base(TypedDict): pass
class Record(Middle, extra_items="not a type"): pass
```
This valid code now produces a false F722. `Middle` inherits the module-level `Base`, but the lookup
finds `Namespace.Base`. The inheritance walk should use each base expressionβs original binding.
</details>
[`convert-typed-dict-functional-to-class` (`UP013`)]: https://docs.astral.sh/ruff/rules/convert-typed-dict-functional-to-class/
[PEP 728]: https://peps.python.org/pep-0728/
Test Plan
--
New mdtests
|
ntBre
marked this pull request as ready for review
September 11, 2026 20:16
MichaReiser
approved these changes
Sep 14, 2026
MichaReiser
left a comment
Member
There was a problem hiding this comment.
Thank you.
It seems that the functional TypedDict(...) visitor visits every expression as a type/annotation expression, including total and closed. Again, this is a pre-existing issue, but it would be great if the behavior is consistent with class based TypedDicts (an allowlist for type expressions or a deny list for value expressions)
ntBre
added a commit
that referenced
this pull request
Sep 14, 2026
Summary -- This is a follow-up to the review on #28533. We were previously visiting all of the keyword arguments to functional `TypedDict` constructor calls as type definitions, in line with this usage: ```pycon >>> from typing import TypedDict >>> Point2D = TypedDict('Point2D', x=int, y=int, label=str) >>> Point2D.__required_keys__ frozenset({'x', 'y', 'label'}) ``` However, this usage was actually deprecated in 3.11 and fully removed in 3.13, paving the way for the new `closed` keyword added in 3.15. This PR updates our `TypedDict` keyword argument traversal to visit only these legacy arguments and the new `extra_items` argument as types and to visit other arguments as non-types instead. Test Plan -- New mdtests and a couple of updates to existing snapshots
ntBre
added a commit
that referenced
this pull request
Sep 15, 2026
Summary -- This is a follow-up to the review on #28533. We were previously visiting all of the keyword arguments to functional `TypedDict` constructor calls as type definitions, in line with this usage: ```pycon >>> from typing import TypedDict >>> Point2D = TypedDict('Point2D', x=int, y=int, label=str) >>> Point2D.__required_keys__ frozenset({'x', 'y', 'label'}) ``` However, this usage was actually deprecated in 3.11 and fully removed in 3.13, paving the way for the new `closed` keyword added in 3.15. This PR updates our `TypedDict` keyword argument traversal to visit only these legacy arguments and the new `extra_items` argument as types and to visit other arguments as non-types instead. Test Plan -- New mdtests and a couple of updates to existing snapshots
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PEP 728 introduced two new keyword arguments for
TypedDicts: the booleanclosedargument andextra_items, which allows specifying the type for any additional entries in theTypedDict. Seethis example from the PEP:
Like some of my previous 3.15 PRs, this PR adds support for visiting the value of the
extra_itemskeyword argument as a type expression, again using
F401to demonstrate that such quotedexpressions mark their corresponding imports as used. This requires a little bit of special handling
when visiting class statements, which can also be deferred in stub files, but overall wasn't too bad
I think.
closeddoesn't need any special handling in the semantic model since it's just a flag,not a type expression.
This PR also updates
convert-typed-dict-functional-to-class(UP013) to recognize the newkeywords and carry them along in its fix.
The first commit contains the visitor changes, and the second contains the
UP013update.Once again, my Codex reviewers found an existing bug that this exposes in a new way. We could
sidestep this, and substantially simplify the code at the same time, by dropping the recursive base
class traversal, but it seemed more useful to me to keep the recursive traversal and fix the other
bug later if needed. Either unhandled case (multiple levels of inheritance in one file, or
inheritance + shadowing + unrelated class with
extra_items) seems quite niche, though.`any_base_class` bug
Ancestor lookup can misclassify an ordinary class as a TypedDict β The lookup resolves ancestor
names in the current scope, so an unrelated definition can shadow the actual base:
This valid code now produces a false F722.
Middleinherits the module-levelBase, but the lookupfinds
Namespace.Base. The inheritance walk should use each base expressionβs original binding.Test Plan
New mdtests