Skip to content
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

Add: refinement of ReadOnly TypedDict fields in subclasses #18704

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions mypy/semanal_typeddict.py
Original file line number Diff line number Diff line change
@@ -2,7 +2,6 @@

from __future__ import annotations

from collections.abc import Collection
from typing import Final

from mypy import errorcodes as codes, message_registry
@@ -40,6 +39,7 @@
require_bool_literal_argument,
)
from mypy.state import state
from mypy.subtypes import is_subtype
Copy link
Member

Choose a reason for hiding this comment

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

Unfortunately, it is not safe to call is_subtype() during semantic analysis (except in semanal_typeargs.py which is the last pass, but this is not helpful for you). Some types may not be fully analyzed and this will cause all kinds of weird behaviors.

This kind of checks can be only done during type-checking phase.

from mypy.typeanal import check_for_explicit_any, has_any_from_unimported_type
from mypy.types import (
TPDICT_NAMES,
@@ -166,7 +166,9 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N
base, field_types, required_keys, readonly_keys, defn
)
(new_field_types, new_statements, new_required_keys, new_readonly_keys) = (
self.analyze_typeddict_classdef_fields(defn, oldfields=field_types)
self.analyze_typeddict_classdef_fields(
defn, oldfields=field_types, oldreadonly_keys=readonly_keys
)
)
if new_field_types is None:
return True, None # Defer
@@ -280,7 +282,10 @@ def map_items_to_base(
return mapped_items

def analyze_typeddict_classdef_fields(
self, defn: ClassDef, oldfields: Collection[str] | None = None
self,
defn: ClassDef,
oldfields: dict[str, Type] | None = None,
oldreadonly_keys: set[str] | None = None,
) -> tuple[dict[str, Type] | None, list[Statement], set[str], set[str]]:
"""Analyze fields defined in a TypedDict class definition.

@@ -325,8 +330,6 @@ def analyze_typeddict_classdef_fields(
self.fail(TPDICT_CLASS_ERROR, stmt)
else:
name = stmt.lvalues[0].name
if name in (oldfields or []):
self.fail(f'Overwriting TypedDict field "{name}" while extending', stmt)
if name in fields:
self.fail(f'Duplicate TypedDict key "{name}"', stmt)
continue
@@ -351,6 +354,26 @@ def analyze_typeddict_classdef_fields(
stmt.type = self.extract_meta_info(analyzed, stmt)[0]

field_type, required, readonly = self.extract_meta_info(field_type)

if oldfields and name in oldfields:
# Refinements are only allowed on readonly keys
if name not in (oldreadonly_keys or set()):
self.fail(f'Overwriting TypedDict field "{name}" while extending', stmt)
else:
# Refinements must be ReadOnly too
if not readonly:
self.fail(
f'Overwriting TypedDict ReadOnly field "{name}" with non-ReadOnly type',
stmt,
)

# Refinements must be type compatible
if not is_subtype(field_type, oldfields[name]):
self.fail(
f'Overwriting TypedDict ReadOnly field "{name}" with incompatible type',
stmt,
)

fields[name] = field_type

if (total or required is True) and required is not False:
53 changes: 53 additions & 0 deletions test-data/unit/check-typeddict.test
Original file line number Diff line number Diff line change
@@ -4138,3 +4138,56 @@ Derived.Params(name="Robert")
DerivedOverride.Params(name="Robert")
[builtins fixtures/dict.pyi]
[typing fixtures/typing-typeddict.pyi]


[case testRefinementReadOnlyField]
from typing_extensions import TypedDict, ReadOnly, Literal

class A(TypedDict):
a: ReadOnly[str]

class B(A):
a: Literal["foo"] # E: Overwriting TypedDict ReadOnly field "a" with non-ReadOnly type

class C(TypedDict):
a: str

class D(C):
a: Literal["foo"] # E: Overwriting TypedDict field "a" while extending

class F(TypedDict):
a: ReadOnly[str]

class G(F):
a: str # E: Overwriting TypedDict ReadOnly field "a" with non-ReadOnly type

class H(TypedDict):
a: ReadOnly[str]

class E(H):
a: ReadOnly[int] # E: Overwriting TypedDict ReadOnly field "a" with incompatible type


class S(str): pass
class I(TypedDict):
a: ReadOnly[str]

class J(I):
a: ReadOnly[S]


def f(a: A, b: B) -> None:
reveal_type(a['a']) # N: Revealed type is "builtins.str"
reveal_type(b['a']) # N: Revealed type is "Literal['foo']"

def g(i: I, j: J) -> None:
reveal_type(i['a']) # N: Revealed type is "builtins.str"
reveal_type(j['a']) # N: Revealed type is "__main__.S"

def mutate_dictA(d: A) -> None:
d["a"] = "bar" # E: ReadOnly TypedDict key "a" TypedDict is mutated

def mutate_dictB(d: B) -> None:
d["a"] = "bar" # E: ReadOnly TypedDict key "a" TypedDict is mutated # E: Value of "a" has incompatible type "Literal['bar']"; expected "Literal['foo']"

[builtins fixtures/primitives.pyi]