类型检查动态添加的属性

Typechecking dynamically added attributes

在编写特定于项目的 pytest 插件时,我经常发现 Config 对象对于附加我自己的属性很有用。示例:

from _pytest.config import Config


def pytest_configure(config: Config) -> None:
    config.fizz = "buzz"

def pytest_unconfigure(config: Config) -> None:
    print(config.fizz)

显然,_pytest.config.Config class 中没有 fizz 属性,因此 运行 mypy 上面的代码片段会产生

conftest.py:5: error: "Config" has no attribute "fizz"
conftest.py:8: error: "Config" has no attribute "fizz"

(请注意 pytest 还没有带有类型提示的版本,所以如果你想在本地实际重现错误,请按照 this comment 中的步骤安装一个分支)。

有时重新定义 class 进行类型检查可以提供快速帮助:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from _pytest.config import Config as _Config

    class Config(_Config):
        fizz: str

else:
    from _pytest.config import Config



def pytest_configure(config: Config) -> None:
    config.fizz = "buzz"

def pytest_unconfigure(config: Config) -> None:
    print(config.fizz)

然而,除了使代码混乱之外,subclassing 解决方法非常有限:添加例如

from pytest import Session


def pytest_sessionstart(session: Session) -> None:
    session.config.fizz = "buzz"

会迫使我也覆盖 Session 进行类型检查。

解决此问题的最佳方法是什么? Config 是一个例子,但我通常在每个项目中还有几个(针对测试 collection/invocation/reporting 等的项目特定调整)。我可以想象编写我自己的 pytest 存根版本,但是我需要为每个项目重复这个,这非常乏味。

这样做的一种方法是设法让您的 Config 对象定义 __getattr____setattr__ 方法。如果这些方法在 class 中定义,mypy 将使用它们来键入检查您正在访问或设置某些未定义属性的位置。

例如:

from typing import Any

class Config:
    def __init__(self) -> None:
        self.always_available = 1

    def __getattr__(self, name: str) -> Any: pass

    def __setattr__(self, name: str, value: Any) -> None: pass

c = Config()

# Revealed types are 'int' and 'Any' respectively
reveal_type(c.always_available)
reveal_type(c.missing_attr)

# The first assignment type checks, but the second doesn't: since
# 'already_available' is a predefined attr, mypy won't try using
# `__setattr__`.
c.dummy = "foo"
c.always_available = "foo"

如果您确定您的临时属性将始终是 strs 或其他内容,您可以键入 __getattr____setattr__ 到 return 或接受 str Any 分别获得更紧密的类型。

不幸的是,您仍然需要执行子类型化技巧或制作自己的存根 - 这给您带来的唯一好处是您至少不必列出每个自定义 属性 你想设置并使创建真正可重用的东西成为可能。这可能会让您更喜欢这个选项,不确定。

您可以探索的其他选项包括:

  • 只需在使用临时 属性 的每一行添加一个 # type: ignore 注释。这将是一种抑制错误消息的精确方法,如果是侵入性的。
  • 键入您的 pytest_configurepytest_unconfigure,以便它们接受 Any 类型的对象。这是抑制错误消息的一种侵入性较小的方法。如果你想最小化使用 Any 的爆炸半径,你可以将任何想要使用这些自定义属性的逻辑限制在它们自己的专用函数中,并在其他任何地方继续使用 Config
  • 尝试改用转换。例如,在 pytest_configure 中你可以做 config = cast(MutableConfig, config) 其中 MutableConfig 是一个 class 你写了 subclasses _pytest.Config 并且定义了两个 __getattr____setattr__。这可能是上述两种方法之间的中间立场。
  • 如果将临时属性添加到 Config 和类似的 classes 是一种常见的事情,也许可以尝试说服 pytest 维护者包含仅输入 __getattr____setattr__ 类型提示中的定义——或者其他一些更专门的方式让用户添加这些动态属性。

您可以通过一个 dict 的新属性扩展 Config class 并存储所有自定义信息。例如:

def pytest_configure(config: Config) -> None:
    config.data["fizz"] = "buzz"  # `data` is the custom dict

这样一来,一个自定义存根文件就适合您的所有项目。当然,它不会立即帮助您的旧项目,因为您需要重写相关部分以使用 data['fizz'] 而不是 fizz。然而,使用 dict 的另一个好处是它可以防止现有属性和自定义属性之间可能发生的名称冲突。

如果将自定义数据附加到 Config 对象是常见的做法,也许值得尝试以数据字典的形式对其进行标准化,并在 pytest 项目中打开相应的问题。

如果您不喜欢重写代码但仍想使用静态类型检查器,您仍然可以使用从某个模板生成的自定义每个项目存根文件。您可以将所有自定义属性直接列为自定义 class 上的注释,然后让脚本从中生成相应的存根文件:

from _pytest.config import Config as _Config

class Config(_Config):
    fizz: str

# The above code can be used by a script to generate custom stub files.