函数调用作为 Python 字段注释

Function calls as Python field annotations

我正在开发一个小模块,利用注释来包含额外的内容 通过使用函数调用作为注释来获取关于 class 字段的数据(参见代码 以下)。我正在尝试一种方法来做到这一点,同时保持与静态类型检查的兼容性。 (旁注:我在充分了解 PEP 563 和注释的推迟评估的情况下这样做)

我通过 mypy 0.670 和 pycharm 2019.2.4 获得了以下代码。 mypy 在 value 字段的声明中报告“错误:无效类型注释或注释”。但是,pycharm 推断值字段是一个整数。

pycharm好像已经确定了函数调用的结果 its_an_int()int 类型,因此能够将该字段视为 用于静态类型检查和其他 IDE 功能的整数。这是理想和 我希望 Python 类型检查可以完成什么。

我主要依赖pycharm,不使用mypy。不过,我很谨慎 关于使用此设计是否会与之发生冲突 "sane" 用于类型注释,特别是如果其他类型检查器喜欢 mypy 在这方面会失败。

正如 PEP 563 所说,“使用与上述 PEP 不兼容的注释应被视为弃用。”。我认为这意味着注释主要用于指示类型,但我在任何 PEP 中都看不到任何阻止在注释中使用表达式的内容。据推测,本身可以静态分析的表达式将是可接受的注释。

是否合理期望下面的value字段可以 被静态分析推断为当前为 Python 定义的整数 3.8(到 4.0)? mypy 的分析是否过于严格或有限?或者是 pycharm 是自由主义者?

from __future__ import annotations

import typing


def its_an_int() -> typing.Type[int]:
    # ...magic stuff happens here...
    pass


class Foo:

    # This should be as if "value: int" was declared, but with side effects
    # once the annotation is evaluted.
    value: its_an_int()

    def __init__(self, value):
        self.value = value


def y(a: str) -> str:
    return a.upper()


f = Foo(1)

# This call will fail since it is passing an int instead of a string.   A 
# static analyzer should flag the argument type as incorrect if value's type
# is known. 
print(y(f.value))

以下可能会满足您的要求;我不知道。基本上,将存在一个函数 test,这样只要用户写入 obj.memvar = y 就会引发错误,除非 test(y) 返回 True。例如 foo 可以测试 y 是否是 int class 的实例。

import typing
import io
import inspect
import string

class TypedInstanceVar:
    def __init__(self, name:str, test:typing.Callable[[object], bool]):
        self._name = name
        self._test = test

    def __get__(descriptor, instance, klass):
        if not instance:
            with io.StringIO() as ss:
                print(
                    "Not a class variable",
                    file=ss
                )
                msg = ss.getvalue()
            raise ValueError(msg)
        return getattr(instance, "_" + descriptor._name)

    @classmethod
    def describe_test(TypedInstanceVar, test:typing.Callable[[object], bool]):
        try:
            desc = inspect.getsource(test)
        except BaseException:
            try:
                desc = test.__name__
            except AttributeError:
                desc = "No description available"
        return desc.strip()

    @classmethod
    def pretty_string_bad_input(TypedInstanceVar, bad_input):
        try:
            input_repr = repr(bad_input)
        except BaseException:
            input_repr = object.__repr__(bad_input)
        lamby = lambda ch:\
            ch if ch in string.printable.replace(string.whitespace, "") else " "
        with io.StringIO() as ss:
            print(
                type(bad_input),
                ''.join(map(lamby, input_repr))[0:20],
                file=ss,
                end=""
            )
            msg = ss.getvalue()
        return msg

    def __set__(descriptor, instance, new_val):
        if not descriptor._test(new_val):
            with io.StringIO() as ss:
                print(
                    "Input " + descriptor.pretty_string_bad_input(new_val),
                    "fails to meet requirements:",
                    descriptor.describe_test(descriptor._test),
                    sep="\n",
                    file=ss
                )
                msg = ss.getvalue()
            raise TypeError(msg)
        setattr(instance, "_" + descriptor._name, new_val)

下面,我们看到 TypedInstanceVar 正在使用:

class Klass:
    x = TypedInstanceVar("x", lambda obj: isinstance(obj, int))
    def __init__(self, x):
        self.x = x
    def set_x(self, x):
        self.x = x

#######################################################################

try:
    instance = Klass(3.4322233)
except TypeError as exc:
    print(type(exc), exc)

instance = Klass(99)
print(instance.x)  # prints 99
instance.set_x(44) # no error
print(instance.x)  # prints 44

try:
    instance.set_x(6.574523)
except TypeError as exc:
    print(type(exc), exc)

作为第二个例子:

def silly_requirement(x):
    status = type(x) in (float, int)
    status = status or len(str(x)) > 52
    status = status or hasattr(x, "__next__")
    return status

class Kalzam:
    memvar = TypedInstanceVar("memvar", silly_requirement)
    def __init__(self, memvar):
        self.memvar = memvar

instance = Kalzam("hello world")

第二个例子的输出是:

TypeError: Input <class 'str'> 'hello world'
fails to meet requirements:
def silly_requirement(x):
    status = type(x) in (float, int)
    status = status or len(str(x)) > 52
    status = status or hasattr(x, "__next__")
    return status

您使用的语法似乎不太可能符合 PEP 484 定义的类型提示。

这部分是因为 PEP 从未声明使用任意表达式作为类型提示是允许的,部分是因为我不认为你的例子真的符合 PEP 484 试图完成的精神。

特别是,Python 打字生态系统的一个重要设计目标是在 "runtime world" 和 "static types" 世界之间保持相当严格的划分。特别是,应该始终可以在运行时完全忽略类型提示,但如果类型提示在评估时有时会产生副作用,则这是不可能的。

并非不可能有人最终会设计一个 PEP 来允许你尝试做的事情并成功地说服它被接受,但我认为没有人在做这样的 PEP 或者如果有很多需求一个。

可能更规范的附加或记录元数据的方法可能是通过执行以下操作使副作用操作明确:

# Alternatively, make this a descriptor class if you want to do
# even fancier things: https://docs.python.org/3/howto/descriptor.html
def magic() -> Any:
    # magic here

class Foo:
    value: int = magic()

    def __init__(self, value):
        self.value = value

...或使用新的 Annotated 类型描述在显然刚刚接受的 PEP 593 中,它允许类型提示和任意非类型提示信息共存:

# Note: it should eventually be possible to import directly from 'typing' in
# future versions of Python, but for now you'll need to pip-install
# typing_extensions, the 'typing' backport.
from typing_extensions import Annotated

def magic():
    # magic here

class Foo:
    value: Annotated[int, magic()]

    def __init__(self, value):
        self.value = value

最后一种方法的主要警告是我不相信 Pycharm 还支持 Annotated 类型提示,因为它是非常新的。


抛开所有这些,值得注意的是,拒绝 PEP 484 并继续使用 Pycharm 碰巧理解的任何东西不一定 是错误的 。 Pycharm 显然可以理解您的示例(也许它是 Pycharm 如何实现类型分析的实现工件?),但如果它对您有用并且将您的代码库调整为符合 PEP 484 太痛苦了,用你现有的东西滚动可能是合理的。

如果您仍然希望您的代码可供 使用 PEP 484 类型提示的其他开发人员使用,您始终可以决定将 pyi 存根文件与您的代码一起分发包,如 PEP 561.

中所述

生成这些存根文件需要大量工作,但存根确实提供了一种方法,让选择不使用 PEP 484 的代码与没有使用 PEP 484 的代码进行互操作。