Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

PEP 705 – TypedDict:只读项

作者:
Alice Purcell <alicederyn at gmail.com>
赞助人:
Pablo Galindo <pablogsal at gmail.com>
讨论邮件列表:
Discourse 线程
状态:
最终
类型:
标准跟踪
主题:
类型提示
创建日期:
2022年11月7日
Python 版本:
3.13
历史记录:
2022年9月30日, 2022年11月2日, 2023年3月14日, 2023年10月17日, 2023年11月4日
决议:
Discourse 消息

目录

注意

此 PEP 是一份历史文档:请参阅 typing.ReadOnly 类型限定符typing.ReadOnly 以获取最新的规范和文档。规范的类型规范维护在 类型规范站点;运行时类型行为在 CPython 文档中进行了描述。

×

请参阅 类型规范更新流程,了解如何提出更改类型规范的建议。

摘要

PEP 589 定义了结构类型 TypedDict,用于键集固定的字典。由于 TypedDict 是一个可变类型,因此很难正确地注释接受只读参数的方法,而不会阻止有效的输入。

此 PEP 提出了一种新的类型限定符 typing.ReadOnly 来支持这些用法。它没有进行任何 Python 语法更改。只读键的正确使用旨在仅由静态类型检查器强制执行,并且 Python 本身在运行时不会强制执行。

动机

使用(可能嵌套的)带有字符串键的字典来表示结构化数据是 Python 程序中的一种常见模式。PEP 589 允许在预先知道确切类型时对这些值进行类型检查,但很难编写接受更具体变体的只读代码:例如,其中值可能是子类型或限制可能的类型的联合。在为服务编写 API 时,这是一个特别常见的问题,这些服务可能支持各种输入结构,并且通常不需要修改其输入。

纯函数

考虑尝试向函数 movie_string 添加类型提示

def movie_string(movie: Movie) -> str:
    if movie.get("year") is None:
        return movie["name"]
    else:
        return f'{movie["name"]} ({movie["year"]})'

我们可以使用 TypedDict 定义此 Movie 类型

from typing import NotRequired, TypedDict

class Movie(TypedDict):
    name: str
    year: NotRequired[int | None]

但是假设我们有另一种类型,其中 year 是必需的

class MovieRecord(TypedDict):
    name: str
    year: int

尝试将 MovieRecord 传递给 movie_string 会导致错误(使用 mypy)

Argument 1 to "movie_string" has incompatible type "MovieRecord"; expected "Movie"

此特定用例应该是类型安全的,但类型检查器会正确地阻止用户在一般情况下将 MovieRecord 传递给 Movie 参数,因为 Movie 类具有可能允许函数破坏 MovieRecord 中类型约束的变异方法(例如,使用 movie["year"] = Nonedel movie["year"])。如果 Movie 中没有变异方法,则问题就会消失。这可以通过使用 PEP 544 Protocol 定义不可变接口来实现。

from typing import Literal, Protocol, overload

class Movie(Protocol):
    @overload
    def get(self, key: Literal["name"]) -> str: ...

    @overload
    def get(self, key: Literal["year"]) -> int | None: ...

    @overload
    def __getitem__(self, key: Literal["name"]) -> str: ...

    @overload
    def __getitem__(self, key: Literal["year"]) -> int | None: ...

这非常重复,容易出错,并且仍然缺少重要的方法定义,例如 __contains__()keys()

更新嵌套字典

TypedDict 的结构类型应该允许编写仅约束其修改的项类型的更新函数

class HasTimestamp(TypedDict):
    timestamp: float

class Logs(TypedDict):
    timestamp: float
    loglines: list[str]

def update_timestamp(d: HasTimestamp) -> None:
    d["timestamp"] = now()

def add_logline(logs: Logs, logline: str) -> None:
    logs["loglines"].append(logline)
    update_timestamp(logs)  # Accepted by type checker

但是,一旦你开始嵌套字典,这就不再起作用了

class HasTimestampedMetadata(TypedDict):
    metadata: HasTimestamp

class UserAudit(TypedDict):
    name: str
    metadata: Logs

def update_metadata_timestamp(d: HasTimestampedMetadata) -> None:
    d["metadata"]["timestamp"] = now()

def rename_user(d: UserAudit, name: str) -> None:
    d["name"] = name
    update_metadata_timestamp(d)  # Type check error: "metadata" is not of type HasTimestamp

这看起来像一个错误,但仅仅是由于(不需要的)能够用不同的 HasTimestamp 实例覆盖 HasTimestampedMetadata 实例持有的 metadata 项的能力,该实例可能不再是 Logs 实例。

可以使用泛型(从 Python 3.11 开始)解决此问题,但这非常复杂,需要为每个嵌套字典提供一个类型参数。

基本原理

可以通过删除更新 TypedDict 中一个或多个项的能力来解决这些问题。这并不意味着这些项是不可变的:对底层字典的引用仍然可能存在,并且具有不同的但兼容的类型,其中这些项具有变异操作。这些项是“只读”的,为此我们引入了一个新的 typing.ReadOnly 类型限定符。

第一个激励示例中的 movie_string 函数可以按如下方式进行类型化

from typing import NotRequired, ReadOnly, TypedDict

class Movie(TypedDict):
    name: ReadOnly[str]
    year: ReadOnly[NotRequired[int | None]]

def movie_string(movie: Movie) -> str:
    if movie.get("year") is None:
        return movie["name"]
    else:
        return f'{movie["name"]} ({movie["year"]})'

允许混合使用只读和非只读项,从而允许正确地注释第二个激励示例

class HasTimestamp(TypedDict):
    timestamp: float

class HasTimestampedMetadata(TypedDict):
    metadata: ReadOnly[HasTimestamp]

def update_metadata_timestamp(d: HasTimestampedMetadata) -> None:
    d["metadata"]["timestamp"] = now()

class Logs(HasTimestamp):
    loglines: list[str]

class UserAudit(TypedDict):
    name: str
    metadata: Logs

def rename_user(d: UserAudit, name: str) -> None:
    d["name"] = name
    update_metadata_timestamp(d)  # Now OK

除了这些好处之外,通过将函数的参数标记为只读(通过使用具有只读项的 TypedDict,例如 Movie),它不仅对类型检查器而且对用户明确了函数不会修改其输入,这通常是函数接口的一个理想属性。

此 PEP 提出使 ReadOnly 仅在 TypedDict 中有效。可能的未来扩展是在其他上下文中支持它,例如在协议中。

规范

添加了一个新的 typing.ReadOnly 类型限定符。

typing.ReadOnly 类型限定符

typing.ReadOnly 类型限定符用于指示在 TypedDict 定义中声明的项不得被修改(添加、修改或删除)。

from typing import ReadOnly

class Band(TypedDict):
    name: str
    members: ReadOnly[list[str]]

blur: Band = {"name": "blur", "members": []}
blur["name"] = "Blur"  # OK: "name" is not read-only
blur["members"] = ["Damon Albarn"]  # Type check error: "members" is read-only
blur["members"].append("Damon Albarn")  # OK: list is mutable

替代函数语法

TypedDict 的 替代函数语法 也支持新的类型限定符

Band = TypedDict("Band", {"name": str, "members": ReadOnly[list[str]]})

与其他特殊类型的交互

ReadOnly[] 可以与 Required[]NotRequired[]Annotated[] 结合使用,以任何嵌套顺序。

class Movie(TypedDict):
    title: ReadOnly[Required[str]]  # OK
    year: ReadOnly[NotRequired[Annotated[int, ValueRange(-9999, 9999)]]]  # OK
class Movie(TypedDict):
    title: Required[ReadOnly[str]]  # OK
    year: Annotated[NotRequired[ReadOnly[int]], ValueRange(-9999, 9999)]  # OK

这与 PEP 655 中引入的行为一致。

继承

子类可以将只读项重新声明为非只读,从而允许对其进行修改

class NamedDict(TypedDict):
    name: ReadOnly[str]

class Album(NamedDict):
    name: str
    year: int

album: Album = { "name": "Flood", "year": 1990 }
album["year"] = 1973
album["name"] = "Dark Side Of The Moon"  # OK: "name" is not read-only in Album

如果未重新声明只读项,则它将保持只读

class Album(NamedDict):
    year: int

album: Album = { "name": "Flood", "year": 1990 }
album["name"] = "Dark Side Of The Moon"  # Type check error: "name" is read-only in Album

子类可以缩小只读项的值类型

class AlbumCollection(TypedDict):
    albums: ReadOnly[Collection[Album]]

class RecordShop(AlbumCollection):
    name: str
    albums: ReadOnly[list[Album]]  # OK: "albums" is read-only in AlbumCollection

子类可以要求在超类中不是必需的但为只读的项

class OptionalName(TypedDict):
    name: ReadOnly[NotRequired[str]]

class RequiredName(OptionalName):
    name: ReadOnly[Required[str]]

d: RequiredName = {}  # Type check error: "name" required

子类可以组合这些规则

class OptionalIdent(TypedDict):
    ident: ReadOnly[NotRequired[str | int]]

class User(OptionalIdent):
    ident: str  # Required, mutable, and not an int

请注意,这些只是结构类型的结果,但此处突出显示了它们,因为行为现在与 PEP 589 中指定的规则不同。

类型一致性

本节更新了 PEP 589 中引入的类型一致性规则,以涵盖此 PEP 中的新功能。特别是,在这些新规则下,任何不使用新功能的类型对,如果(且仅当)它们已经一致时,将是一致的。

如果 A 在结构上与 B 兼容,则 TypedDict 类型 A 与 TypedDict B 一致。当且仅当满足以下所有条件时,才为真

  • 对于 B 中的每个项,A 具有相应的键,除非 B 中的项为只读、非必需且为顶级值类型(ReadOnly[NotRequired[object]])。
  • 对于 B 中的每个项,如果 A 具有相应的键,则 A 中相应的类型与 B 中的值类型一致。
  • 对于 B 中每个非只读项,其值类型与 A 中对应的值类型一致。
  • 对于 B 中每个必需键,对应的键在 A 中也是必需的。
  • 对于 B 中每个非必需键,如果该项在 B 中不是只读的,则对应的键在 A 中不是必需的。

讨论

  • TypedDict 中所有未指定项隐式地具有值类型 ReadOnly[NotRequired[object]]
  • 只读项表现为协变,因为它们不能被修改。这类似于 Sequence 等容器类型,并且不同于非只读项,非只读项表现为不变。示例
    class A(TypedDict):
        x: ReadOnly[int | None]
    
    class B(TypedDict):
        x: int
    
    def f(a: A) -> None:
        print(a["x"] or 0)
    
    b: B = {"x": 1}
    f(b)  # Accepted by type checker
    
  • 没有显式键 'x' 的 TypedDict 类型 A 与具有非必需键 'x' 的 TypedDict 类型 B 不一致,因为在运行时键 'x' 可能存在并具有不兼容的类型(由于结构化子类型,这可能无法通过 A 看出来)。此规则的唯一例外是,如果 B 中的项是只读的,并且值类型为顶级类型(object)。例如
    class A(TypedDict):
        x: int
    
    class B(TypedDict):
        x: int
        y: ReadOnly[NotRequired[object]]
    
    a: A = { "x": 1 }
    b: B = a  # Accepted by type checker
    

更新方法

除了现有的类型检查规则外,如果使用声明该键的另一个 TypedDict 更新具有只读项的 TypedDict,类型检查器应该报错。

class A(TypedDict):
    x: ReadOnly[int]
    y: int

a1: A = { "x": 1, "y": 2 }
a2: A = { "x": 3, "y": 4 }
a1.update(a2)  # Type check error: "x" is read-only in A

除非声明的值为底层类型(Never

class B(TypedDict):
    x: NotRequired[typing.Never]
    y: ReadOnly[int]

def update_a(a: A, b: B) -> None:
    a.update(b)  # Accepted by type checker: "x" cannot be set on b

注意:没有任何内容会匹配 Never 类型,因此用它注释的项必须不存在。

关键字参数类型提示

PEP 692 引入了 Unpack 以使用 TypedDict 注释 **kwargs。将以这种方式使用的方法的 TypedDict 中的一个或多个项标记为只读不会影响该方法的类型签名。但是,它阻止在函数体中修改该项。

class Args(TypedDict):
    key1: int
    key2: str

class ReadOnlyArgs(TypedDict):
    key1: ReadOnly[int]
    key2: ReadOnly[str]

class Function(Protocol):
    def __call__(self, **kwargs: Unpack[Args]) -> None: ...

def impl(**kwargs: Unpack[ReadOnlyArgs]) -> None:
    kwargs["key1"] = 3  # Type check error: key1 is readonly

fn: Function = impl  # Accepted by type checker: function signatures are identical

运行时行为

TypedDict 类型将获得两个新属性,__readonly_keys____mutable_keys__,它们将分别为包含所有只读键和非只读键的冻结集合。

class Example(TypedDict):
    a: int
    b: ReadOnly[int]
    c: int
    d: ReadOnly[int]

assert Example.__readonly_keys__ == frozenset({'b', 'd'})
assert Example.__mutable_keys__ == frozenset({'a', 'c'})

typing.get_type_hints 将去除任何 ReadOnly 类型限定符,除非 include_extrasTrue

assert get_type_hints(Example)['b'] == int
assert get_type_hints(Example, include_extras=True)['b'] == ReadOnly[int]

typing.get_origintyping.get_args 将更新为识别 ReadOnly

assert get_origin(ReadOnly[int]) is ReadOnly
assert get_args(ReadOnly[int]) == (int,)

向后兼容性

此 PEP 为 TypedDict 添加了一个新功能,因此检查 TypedDict 类型的代码将不得不更改以支持使用它的类型。这预计主要会影响类型检查器。

安全隐患

此 PEP 没有已知的安全问题。

如何教授这个

typing 模块文档的建议更改,与当前实践一致

  • 将此 PEP 添加到列出的其他 PEP 中。
  • 添加 typing.ReadOnly,链接到 TypedDict 和此 PEP。
  • 将以下文本添加到 TypedDict 条目中

ReadOnly 类型限定符表示在 TypedDict 定义中声明的项可以读取但不能修改(添加、修改或删除)。当尚不知道值的精确类型时,这很有用,因此修改它会破坏结构化子类型。插入示例

参考实现

pyright 1.1.333 完全实现了此提案。.

被拒绝的替代方案

一个 TypedMapping 协议类型

此 PEP 的早期版本提出了一个 TypedMapping 协议类型,其行为类似于只读 TypedDict,但没有运行时类型必须为 dict 的约束。然后可以通过从 TypedMapping 继承 TypedDict 来获得此 PEP 当前版本中描述的行为。由于这更复杂,并且没有强烈的用例来推动额外的复杂性,因此目前已将其搁置。

一个高阶 ReadOnly 类型

可以添加一个广义的高阶类型,它从其参数中删除 mutator 方法,例如 ReadOnly[MovieRecord]。对于 TypedDict,这就像为每个项添加 ReadOnly,包括在超类中声明的项。这自然希望为比 TypedDict 子类更广泛的类型集定义,并且还引发了关于它是否以及如何应用于嵌套类型的问题。我们决定将此 PEP 的范围缩小。

将类型命名为 Readonly

Read-only 通常用连字符连接,并且在转换为 CamelCase 时将初始大写字母放在用连字符分隔的单词上似乎是常见的约定。这似乎与维基百科上 CamelCase 的定义一致:CamelCase 将每个单词的首字母大写。也就是说,Python 示例或反例(理想情况下来自核心 Python 库),或者更好的关于约定的明确指导将不胜感激。

重用 Final 注解

Final 注释阻止修改属性,就像提议的 ReadOnly 限定符对 TypedDict 项所做的那样。但是,它也在文档中说明了阻止在子类中重新定义;来自 PEP 591

typing.Final 类型限定符用于指示不应重新分配、重新定义或覆盖变量或属性。

这与 ReadOnly 的预期用途不符。与其通过让 Final 在不同上下文中表现出不同的行为来造成混淆,我们选择引入一个新的限定符。

一个只读标志

此 PEP 的早期版本引入了一个布尔标志,该标志将确保 TypedDict 中的所有项都是只读的。

class Movie(TypedDict, readonly=True):
    name: str
    year: NotRequired[int | None]

movie: Movie = { "name": "A Clockwork Orange" }
movie["year"] = 1971  # Type check error: "year" is read-only

但是,这在引入继承时导致了混淆。

class A(TypedDict):
    key1: int

class B(A, TypedDict, readonly=True):
    key2: int

b: B = { "key1": 1, "key2": 2 }
b["key1"] = 4  # Accepted by type checker: "key1" is not read-only

对于熟悉 frozen(来自 dataclasses)的人来说,仅查看 B 的定义,假设整个类型都是只读的,这是合理的。另一方面,对于熟悉 total 的人来说,假设只读仅适用于当前类型,这也是合理的。

最初的提案试图通过将其既作为类型检查又作为运行时错误来消除这种歧义,以这种方式定义 B。对于期望它像 total 一样工作的人来说,这仍然是一个令人惊讶的来源。

鉴于使用 readonly 标志无法表达其他类型,因此已从提案中删除了该标志,以避免歧义和意外。

支持通过复制和其他方法类型检查删除只读限定符

此 PEP 的早期版本要求类型检查器支持以下代码

class A(TypedDict):
    x: ReadOnly[int]

class B(TypedDict):
    x: ReadOnly[str]

class C(TypedDict):
    x: int | str

def copy_and_modify(a: A) -> C:
    c: C = copy.copy(a)
    if not c['x']:
        c['x'] = "N/A"
    return c

def merge_and_modify(a: A, b: B) -> C:
    c: C = a | b
    if not c['x']:
        c['x'] = "N/A"
    return c

但是,目前在 typeshed 中无法表达这一点,这意味着类型检查器将被迫对这些函数进行特殊处理。虽然可以说这不太易读,但 mypy 和 pyright 确实支持对这些操作进行编码的方式。

copied: C = { **a }
merged: C = { **a, **b }

虽然不如理想的那么灵活,但当前的 typeshed 存根是健全的,并且如果接受此 PEP,则仍然如此。更新 typeshed 需要新的类型特性,例如一个类型构造函数来表达合并两个或多个字典产生的类型,以及一个类型限定符来指示返回值未共享(因此可能在特定方面具有类型约束,例如只读和泛型的不变性),加上类型检查器如何解释这些特性的详细信息。这些可能是对语言的有价值的补充,但不在此 PEP 的范围之内。

鉴于此,我们已推迟任何 typeshed 存根的更新。

防止在 TypedDict 中出现未指定的键

考虑以下“类型区分”代码

class A(TypedDict):
  foo: int

class B(TypedDict):
  bar: int

def get_field(d: A | B) -> int:
  if "foo" in d:
    return d["foo"]  # !!!
  else:
    return d["bar"]

这是一个常见的习惯用法,其他语言如 Typescript 允许它。然而,从技术上讲,此代码是不安全的:B 没有声明 foo,但 B 的实例可能仍然存在该键,并且关联的值可能是任何类型。

class C(TypedDict):
  foo: str
  bar: int

c: C = { "foo": "hi", "bar" 3 }
b: B = c  # OK: C is structurally compatible with B
v = get_field(b)  # Returns a string at runtime, not an int!

mypy 在标记行上拒绝 get_field 的定义,并显示错误 TypedDict "B" has no key "foo",这是一个相当令人困惑的错误消息,但是由这种不安全性引起的。

一个纠正方法是显式地阻止 B 包含 foo

class B(TypedDict):
  foo: NotRequired[Never]
  bar: int

b: B = c  # Type check error: key "foo" not allowed in B

但是,这需要在每个类型中显式声明可能用于区分的每个可能的键,这通常是不可行的。更好的选择是有一种方法可以防止所有未指定的键包含在 B 中。mypy 使用来自 PEP 591@final 装饰器支持这一点。

@final
class B(TypedDict):
  bar: int

这里的推理是,这阻止了 C 或任何其他类型被视为 B 的“子类”,因此现在可以依赖 B 的实例永远不会包含键 foo,即使它没有被显式声明为底层类型。

但是,随着只读项的引入,此推理意味着类型检查器应该禁止以下内容:

@final
class D(TypedDict):
  field: ReadOnly[Collection[str]]

@final
class E(TypedDict):
  field: list[str]

e: E = { "field": ["value1", "value2"] }
d: D = e  # Error?

这里概念上的问题是 TypedDict 是结构类型:它们实际上不能被子类化。因此,在它们上面使用@final 并没有明确定义;在PEP 591 中肯定没有提到这一点。

PEP 的早期版本建议通过向TypedDict添加一个新标志来解决这个问题,该标志将明确阻止使用其他键,但不会阻止其他类型的结构兼容性。

class B(TypedDict, other_keys=Never):
  bar: int

b: B = c  # Type check error: key "foo" not allowed in B

然而,在起草过程中,情况发生了变化。

  • pyright 在这种类型区分的情况下,之前的工作方式与 mypy 类似,更改为允许原始示例不报错,尽管不安全,因为它是一种常见的习惯用法。
  • mypy 有一个未解决的问题,以遵循 pyright 和 Typescript 的做法,也允许这种习惯用法。
  • 一个PEP-728 的草稿被创建,它是other_keys功能的超集。

因此,在这个 PEP 中解决这个问题的紧迫性降低了,它已被推迟到 PEP-728。


来源:https://github.com/python/peps/blob/main/peps/pep-0705.rst

最后修改时间:2024-09-03 15:53:33 GMT