Skip to content

Recognize PEP-728 TypedDict class keywords - #28533

Merged
ntBre merged 3 commits into
mainfrom
brent/typeddict-keywords
Sep 15, 2026
Merged

ntBre merged 3 commits into
mainfrom
brent/typeddict-keywords

Conversation

@ntBre

@ntBre ntBre commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

PEP 728 introduced two new keyword arguments for TypedDicts: 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:

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.

`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:

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.

Test Plan

New mdtests

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 ntBre added rule Implementing or modifying a lint rule python315 Related to Python 3.15 labels Sep 11, 2026
@ntBre ntBre mentioned this pull request Sep 11, 2026
27 of 32 tasks
@astral-sh-bot

astral-sh-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

ruff-ecosystem results

Linter (stable)

βœ… ecosystem check detected no linter changes.

Linter (preview)

βœ… ecosystem check detected no linter changes.

@ntBre
ntBre marked this pull request as ready for review September 11, 2026 20:16
@ntBre
ntBre requested a review from MichaReiser September 11, 2026 20:16

@MichaReiser MichaReiser left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
ntBre added this pull request to stack #28585 September 14, 2026 18:00
@ntBre
ntBre merged commit 8ecec2b into main Sep 15, 2026
50 checks passed
@ntBre
ntBre deleted the brent/typeddict-keywords branch September 15, 2026 13:42
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python315 Related to Python 3.15 rule Implementing or modifying a lint rule

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants