使用 Enum/Literal 键输入详尽字典的提示

huo*_*uon 15 python enums mypy python-typing typeddict

我正在研究具有广泛类型提示的代码库,由 mypy 检查。在某些情况下,我们有一个从enum.Enum静态已知值或其他小型有限集 ( typing.Literal) 到固定值的映射,因此使用字典很方便:

# GOOD
from enum import Enum, auto

class Foo(Enum):
   X = auto()
   Y = auto()

lookup: dict[Foo, str] = {Foo.X: "cool", Foo.Y: "whatever"}

print(lookup[Foo.X])
Run Code Online (Sandbox Code Playgroud)

然而,这个字典不必是详尽的(又名全部):mypy 对于缺少键非常满意,并且缺少键的索引将在运行时失败。在实践中,对于大型枚举(在定义 时忘记成员dict),或者向现有枚举添加成员时(尤其是在lookup完全不同的文件时),很容易发生这种情况。

例如,这很好地通过了mypy --strict,但在运行时失败了,因为我们“忘记”更新lookup自身:

# BAD
from enum import Enum, auto

class Foo(Enum):
   X = auto()
   Y = auto()
   Z = auto() # NEW

lookup: dict[Foo, str] = {Foo.X: "cool", Foo.Y: "whatever"}
 
print(lookup[Foo.Z]) # CHANGED
Run Code Online (Sandbox Code Playgroud)

我希望能够将特定的字典/映射标记为全部/详尽,这意味着,例如,mypy 将给出有关上面示例lookup中的定义的错误BAD

  1. Enum对于任何类型或Literal[...]键类型,可以使用 Python 当前的类型提示将其注释为泛型类型吗?(例如,我们希望的最佳情况语法是:lookup: ExhaustiveDict[Foo, str] = {...}lookup: ExhaustiveDict[Literal[1, 2], str] = {1: "a", 2: "b"}。)
  2. 如果不是,是否可以针对特定的键/值类型对来完成?(例如,合理的语法可能是lookup: ExhaustiveDictFooTo[str] = {...}and/or lookup: ExhaustiveDictFooToStr = {...},只要这些类型的定义是合理的。)

我很高兴改变我们构建字典的确切语法,但它越接近{Foo.X: "cool", Foo.Y: "whatever"}


背景附加说明/明确我们的理解:

  • 我们目前正在使用一种详尽的解决方法ifdict,但是从紧凑的函数变成完整的函数 很烦人:
    from typing import NoReturn
    def exhaustive(val: NoReturn) -> NoReturn:
        raise NotImplementedError(val)
    
    def lookup(val: Foo) -> str:
        if val is Foo.X:
            return "cool"
        elif val is Foo.Y:
            return "whatever"
        else:
            exhaustive(val)
    
    Run Code Online (Sandbox Code Playgroud) 如果Foo稍后更改为 include Z,我们会在最后一行收到一个错误,例如Argument 1 to "exhaustive" has incompatible type "Literal[Foo.Z]"; expected "NoReturn",这意味着我们没有在前面处理该情况if(该消息并不是立即显而易见的,但它最终只是模式匹配它的含义,并且是比没有好得多)。(大概这也可以使用match/ case,但我们仍然使用 Python 3.9,而不是 3.10。)
  • 这同样适用于使用Literal[1, 2, 3]Literal["foo", "bar", "baz"]作为键类型enum.Enum
  • 这与typing.TypedDict它的total=True默认值有一些重叠,但是 AFAICT,这仅限于字面写入的字符串键TypedDict(因此我们需要将枚举转换为字符串,并具有验证定义TypedDict实际上与枚举匹配的附加功能)。
  • 我基本上是在问如何编写一个与TypeScript 的Recordtype等价的 Python ,比如Record[Foo, str]or Record[Literal["foo", "bar"], str](相当于Record<"foo" | "bar", string>Typescript 中的)。

bad*_*der 10

\n

(...)将特定的字典/映射标记为全部/详尽,这意味着,lookup (...) mypy 将给出有关BAD 示例中的定义的错误

\n
\n

IOW,粗体句子所说的是:创建从to的完整性/详尽性的类型提示依赖。大致在 UML 中:lookupFoo

\n

在此输入图像描述

\n

依赖关系的含义lookup取决于 的更改Foo,但具有合并的四重要求:

\n

A.仅使用类型提示来实现依赖关系(因此是静态类型检查器依赖关系,而不是运行时依赖关系)。

\n

B. 依赖关系会自动反映 的更改Foo,而TypedDict无需在更改时重写类型提示Foo。 (这完全超出了顶部。)

\n

C. 依赖关系导致 mypy 在不满足时发出警告。

\n

D.lookup字典与成员保持整体关系Foo

\n

简单的答案是:不。 Python 没有一种本机类型提示来建立这种依赖关系;并且它无法通过组合静态类型提示来实现,而不需要重写Foo来保持TypedDict同步。因此,唯一的选择是求助于运行时实现,或者重写定义TypedDict以反映对Foo. (即:不可能同时满足要求 A 和 B。)

\n

(困难的部分是演示“为什么不”,因此以下几点尝试建立一个增量演示,解决问题提到的几种可能性。)

\n

1. 声明

\n

1.1. Literal并且TypedDict必须在声明时完整编写,它们的语法规则不允许编写动态声明。因此,无法在声明时将类型提示之间的依赖关系写入类型提示lookup: dict中。Foo: Enum(这是不可能的,所以同时满足要求 B 和 A。)

\n

请参阅下面的 PEP 引用:不可能通过解包或其他运行时方式来声明 Literal[*Foo],同样的情况也是如此,TypedDict因为它没有构造函数(除了显式的类语法和替代语法),这将允许将声明填充为 EnumFoo或 dictlookup类型提示的函数,以捕获依赖项,而无需显式完整地编写它。

\n
\n

PEP 586 - 类型检查时文字参数非法

\n

设计故意不允许使用以下参数:

\n

任意表达式,如Literal[3 + 4] 或 Literal["foo".replace("o", "b")]。

\n

(...)

\n

任何其他类型:例如,Literal[Path] 或 Literal[some_object_instance] 都是非法的。这包括 typevars:如果 T 是 typevar,则不允许 Literal[T]。类型变量只能根据类型而变化,而不能根据值而变化。

\n
\n

具体到TypeDict

\n
\n

PEP 589 \xe2\x80\x93 TypedDict:具有固定键集的字典的类型提示

\n

抽象的

\n

此 PEP 提出了类型构造函数 Typing.TypedDict 以支持字典对象具有一组特定字符串键的用例,每个键都有一个特定类型的值。\n基于类的语法\n字符串文字前向引用在值类型

\n

此 PEP 提出了一个类型构造函数 Typing.TypedDict 来支持字典对象具有一组特定的字符串键的用例,每个键都有一个特定类型的值。

\n
\n

1.2.使用前向引用不会改变这样一个事实:对 Enum 成员的引用只能写入值而不是键(使用类语法)(不满足要求 A)。

\n
class Movie1(TypedDict):\n    cool: "Literal[Foo.X]"\n    whatever: "Literal[Foo.Y]"\n\n\nclass Movie2(TypedDict):\n    cool: Literal[Foo.X]\n    whatever: Literal[Foo.Y]\n
Run Code Online (Sandbox Code Playgroud)\n

1.3.中的键TypedDict必须是字符串,但字符串不能有点(它与点语法冲突)并且不能写为字符串文字。所以下面的三个例子是行不通的(不满足要求A):

\n
class Movie3(TypedDict):  # Illegal  syntax\n    "Foo.X": str\n    "Foo.Y": str\n\nclass Movie4(TypedDict):  # Illegal  syntax\n    Foo.X: str\n    Foo.Y: str\n\n# using a dotted syntax that has no corresponding variable also doesn\'t work\nclass Movie5(TypedDict):  # Illegal syntax\n    a.x: str\n    b.y: str\n
Run Code Online (Sandbox Code Playgroud)\n

1.4.上一点还意味着您可以使用 Enum 成员的别名,以便将它们写入TypedDict,以下代码将起作用:

\n

然而,这将再次违背问题的主要目的,即不必编写和维护需要更新以反映枚举更改的第二组声明。 (再次未满足要求 B。)

\n
some_alias1 = Foo.X\nsome_alias2 = Foo.Y\n\nclass Movie6(TypedDict):\n    some_alias1 : str\n    some_alias2 : str\n\nlookup_forward_ref3: Movie3 = {\'some_alias1\': "cool", \'some_alias2\': "whatever"}\n
Run Code Online (Sandbox Code Playgroud)\n

1.5 TypeDict的替代语法

\n

使用替代语法TypeDict(与类语法相反)可以避免前面提到的点语法问题(在 1.3 中),以下通过 mypy 0.931

\n
class Foo(Enum):\n   X = auto()\n   Y = auto()\n   Z = auto() # NEW\n\nMovie = TypedDict(\'Movie\',\n                  {\n                     \'Foo.X\': str,\n                     \'Foo.Y\': str,\n                     \'Foo.Z\': Literal[Foo.X]},   # just as a Literal example\n                  total=False)\n\nlookup2: Movie = {\'Foo.X\': "cool", \'Foo.Y\': "whatever", \'Foo.Z\': Foo.X}\n
Run Code Online (Sandbox Code Playgroud)\n

这距离您所要求的可能替代方案更近了一步:

\n
\n

我很高兴改变我们构建字典的确切语法,但它越接近越好{Foo.X: "cool", Foo.Y: "whatever"}

\n
\n

但是,您仍然必须保持TypedDict声明与 Enum 的更改同步Foo(因此它不满足要求 B,但要求 A、C 和 D 非常接近)。例如,如果您尝试使用更动态的内容填充键值:

\n
part_declaration = {\n                     \'Foo.X\': str,\n                     \'Foo.Y\': str,\n                     \'Foo.Z\': Literal[Foo.X]}\n\nMovie = TypedDict(\'Movie\',\n                  part_declaration,\n                  total=False)\n
Run Code Online (Sandbox Code Playgroud)\n

Mypy 会提醒您:

\n
your_module.py:27: error: TypedDict() expects a dictionary literal as the second argument\nyour_module.py:31: error: Extra keys ("Foo.X", "Foo.Y", "Foo.Z") for TypedDict "TypedDict"\n
Run Code Online (Sandbox Code Playgroud)\n

1.6 最终值和文字类型的使用

\n

应该强调的是,使用Literals 作为 the 的键仅对字符串文字TypedDict合法,对枚举文字不合法(请注意 PEP 引号中的粗体)。因此,必须完整申报;寻找 Enum的解决方案不会改变这一事实。 (再次未满足要求 B)。TypedDictLiteral

\n
\n

PEP 589 \xe2\x80\x93 TypedDict:具有固定键集的字典的类型提示

\n

最终值和文字类型的使用

\n

类型检查器应允许使用带有字符串值的最终名称 (PEP 591),而不是字符串文字

\n

类型检查器仅期望支持实际的字符串文字,而不是最终名称或文字类型,

\n
\n

Mypy 还认为枚举文字是最终的,请参阅额外的枚举检查,但这并不能取代上述字符串文字限制。

\n

2 与Literal[YourEnum.member]之间的关系YourEnum

\n

the_var: Foo在大多数情况下,键入变量 as或 the_var: Literal[Foo.X, Foo.Y, Foo.Z]]具有所有 Enum 成员之间没有区别Literal,因为它将接受完全相同的类型。\n问题提到使用Literals 而不是Foo(Enum 成员是 Enum 的子类,因此名义上子类型规则适用)。但就问题的目的而言,使用 Literals 不会解决在和之间创建类型提示依赖关系的问题,该依赖关系反映了对后者的更改,而无需重写(同样不满足要求 B)。\n以下两个声明是等效的:lookupFoo

\n
class Foo(Enum):\n   X = auto()\n   Y = auto()\n   Z = auto()\n\nvar1: Foo\nvar2: Literal[Foo.X, Foo.Y, Foo.Z]\n\nvar1 = Foo.X\nvar1 = Foo.Y\nvar1 = Foo.Z\n\nvar2 = Foo.X\nvar2 = Foo.Y\nvar2 = Foo.Z\n
Run Code Online (Sandbox Code Playgroud)\n

现在让我们看看问题中提到的两个属性:

\n

3.整体性

\n

的财产TypeDict。如前所述,TypeDict定义必须在声明时完整写入 - 如果不在声明中显式写入这些更改,则TypedDict键无法反映对 Enum 的更改。 Foo(再次未满足要求 B。)

\n

TypedDict是对其值的类型及其键的字符串值的依赖关系的定义。总体性旨在捕获的是实例TypedDict和类型本身之间的依赖关系。因此,尝试表达与另一种类型的整体依赖关系只能通过显式编码该依赖关系来完成。 (如果不满足要求 B,也可以满足要求 A 和 C,但您必须手动维护这些依赖项为最新)。

\n

4. 详尽性

\n

问题中提到了枚举的这一属性(请参阅PEP 596 - 与枚举和详尽性检查的交互以及mypy - 详尽性检查),但它与要求 A、B 正交。

\n

详尽性是与数据(枚举)相关的逻辑(if/else 分支)。它允许静态类型检查器验证的运行时实现,它不是类型提示! (所以它甚至不属于要求 A - 因为它不是类型提示;它同样不满足要求 B;但它可以满足要求 C,因为您已经实现了它;它通过实现到字符串常量的显式运行时映射而不是使用类型提示来维护字符串和 Enum 成员TypedDict之间的依赖关系,从而规避了要求 D。lookup

\n

5. 结论

\n

如果您发现使用静态类型提示检查永远无法满足要求 B(您必须编写类型提示并维护它们)。大多数开发人员会直接进行单元测试或运行时检查(或者只是抛出 KeyError,因为这样更容易......):

\n
class Foo(Enum):\n    X = auto()\n    Y = auto()\n    Z = auto() # NEW\n\n    @classmethod\n    def totality(cls, lookup: dict[str, Any]):\n        for member in cls:\n            if \'{}.{}\'.format(cls.__qualname__, member.name) not in lookup.keys():\n                raise KeyError  # lookup isn\'t total to Enum.\n
Run Code Online (Sandbox Code Playgroud)\n

类型提示的主要用途是向开发人员提示哪些类型是可接受的。您的使用与此不同,尝试在两组允许的值之间建立映射,并将这些值与映射一起转换为类型。\n这种使用的要点是,如果您忘记在其中维护某些内容,则不会向您发出警告您的代码,但提醒您哪些类型(在本例中是值之间的映射)是可以接受的。

\n

6. 解答问题:

\n
\n

我希望能够将特定字典/映射标记为完整/详尽

\n
\n

可以用 来完成TypedDict。声明类型并将其保持为 Enum 的最新类型Foo

\n
\n

这意味着,例如,mypy 将给出有关上面 BAD 示例中查找定义的错误。

\n
\n

与之前的陈述正交!这与整体性无关,TypedDict整体性与其类型定义相关。保持其与Foo定义相关的完整性并解决问题。

\n
\n
    \n
  1. 对于任何枚举或文字输入,可以使用 Python 的当前类型提示将其注释为泛型类型吗? (例如,查找:ExhaustiveDict[Foo, str] = {...}。)
  2. \n
\n
\n

这个问题没有意义。您作为示例给出的类型提示适用于 Enum 成员,如 (2.) 中所示,并且您没有指定任何文字的含义...?我不知道 Generic 在这里有什么帮助。

\n
\n
    \n
  1. 如果没有,是否可以针对特定的键/值对完成? (例如,查找:ExhaustiveDictFooTo[str] = {...} 和/或查找:ExhaustiveDictFooToStr = {...}。
  2. \n
\n
\n

取决于可能的lookup字典,您只在枚举成员和字符串文字之间提供 1:1 映射,因此没有什么比这更简单的了,它看起来像这样:

\n
combo = tuple[tuple[Literal[Foo.X], Literal[\'cool\']], tuple[Literal[Foo.Y], Literal[\'whatever\']]]\n
Run Code Online (Sandbox Code Playgroud)\n

问题是不可能使用类型提示在字典中表达 1:1 键值关系。这就是将值转换为类型的极端情况......

\n
\n

但从一个紧凑的字典变成一个完整的函数是很烦人的

\n
\n

最简单的解决方案是编写一个TypedDict到 Enum 成员名称的映射作为键(如 1.5 中所述)以及实例lookup。类型提示本身可以写成

\n
class Foo(Enum):\n   X = auto()\n   Y = auto()\n   Z = auto() # NEW\n\nMovie = TypedDict(\'Movie\',\n                  {\n                     \'Foo.X\': str,\n                     \'Foo.Y\': Literal[\'whatever\'], # just as a Literal example\n\n                     \'Foo.Z\': Literal[Foo.X]},  # just as a Literal example\n                  total=False)\n\nlookup2: Movie = {\'Foo.X\': "cool", \'Foo.Y\': \'whatever\', \'Foo.Z\': Foo.X}\n
Run Code Online (Sandbox Code Playgroud)\n

如果你希望 mypy 给你一个警告,你也可以使用详尽性检查(在 4 中),但该警告是为了提醒你在编写逻辑而不是数据时的疏忽!类型提示也没有过时。

\n

  • 正如您可能从我的个人资料中推断出的那样,我熟悉 SO 的工作原理。很抱歉,我的措辞对您来说不清楚,但是要求没有改变(我现在已经明确地调整了问题措辞,但我不愿意与您不必要的攻击(例如“过分”)作斗争/ “很多误解”/……不再)。TypeScript 中的“Record”证明这对于静态类型来说是完全合理的。我怀疑Python的“基本提示规则”除了PEP和实现之外没有任何东西可以阻止它(例如可能是具有合理语法的MyPy插件)。 (4认同)