如何(在运行时)在字典中键入提示/键入检查任意数量的任意键/值对?

2024-04-28 18:07:06 发布

您现在位置:Python中文网/ 问答频道 /正文

我对一个类的定义如下:

from numbers import Number
from typing import Dict

from typeguard import typechecked

Data = Dict[str, Number]

@typechecked
class Foo:
    def __init__(self, data: Data):
        self._data = dict(data)
    @property
    def data(self) -> Data:
        return self._data

我正在使用typeguard。我的意图是限制可以进入数据字典的类型。显然,typeguard会检查整个字典是否传递到函数中或从函数返回。如果字典是直接“公开”的,那么检查类型就成了字典的“责任”——显然,这不起作用:

bar = Foo({'x': 2, 'y': 3}) # ok

bar = Foo({'x': 2, 'y': 3, 'z': 'not allowed'}) # error as expected

bar.data['z'] = 'should also be not allowed but still is ...' # no error, but should cause one

PEP 589引入了类型化词典,但用于一组固定的键(类似于其他语言中类似struct的构造)。相比之下,我需要一个灵活数量的任意键

我最好的坏主意是走“老派”:对dict进行子分类,重新实现API的每一位,通过这些API,数据可以进入(或离开)字典,并向它们添加类型检查:

@typechecked
class TypedDict(dict): # just a sketch
    def __init__(
        self,
        other: Union[Data, None] = None,
        **kwargs: Number,
    ):
        pass # TODO
    def __setitem__(self, key: str, value: Number):
        pass # TODO
    # TODO

是否有一个有效的替代方案不需要“老派”方法


Tags: fromimportself类型numberdata字典foo
2条回答

你的问题似乎有几个部分


(1)在运行时创建类型检查字典

As @juanpa.arrivillaga says in the comments,这与类型-检查有关,但似乎与类型-暗示无关。然而,设计自己的自定义类型检查数据结构是相当简单的。您可以使用^{}这样做:

from collections import UserDict
from numbers import Number

class StrNumberDict(UserDict):
    def __setitem__(self, key, value):
        if not isinstance(key, str):
            raise TypeError(
                f'Invalid type for dictionary key: '
                f'expected "str", got "{type(key).__name__}"'
            )
        if not isinstance(value, Number):
            raise TypeError(
                f'Invalid type for dictionary value: '
                f'expected "Number", got "{type(value).__name__}"'
            )
        super().__setitem__(key, value)

使用中:

>>> d = StrNumberDict()
>>> d['foo'] = 5
>>> d[5] = 6
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "<string>", line 5, in __setitem__
TypeError: Invalid type for dictionary key: expected "str", got "int"
>>> d['bar'] = 'foo'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "<string>", line 10, in __setitem__
TypeError: Invalid type for dictionary value: expected "Number", got "str"

如果你想概括这类事情,你可以这样做:

from collections import UserDict

class TypeCheckedDict(UserDict):
    def __init__(self, key_type, value_type, initdict=None):
        self._key_type = key_type
        self._value_type = value_type
        super().__init__(initdict)

    def __setitem__(self, key, value):
        if not isinstance(key, self._key_type):
            raise TypeError(
                f'Invalid type for dictionary key: '
                f'expected "{self._key_type.__name__}", '
                f'got "{type(key).__name__}"'
            )
        if not isinstance(value, self._value_type):
            raise TypeError(
                f'Invalid type for dictionary value: '
                f'expected "{self._value_type.__name__}", '
                f'got "{type(value).__name__}"'
            )
        super().__setitem__(key, value)

使用中:

>>> from numbers import Number
>>> d = TypeCheckedDict(key_type=str, value_type=Number, initdict={'baz': 3.14})
>>> d['baz']
3.14
>>> d[5] = 5
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "<string>", line 9, in __setitem__
TypeError: Invalid type for dictionary key: expected "str", got "int"
>>> d['foo'] = 'bar'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "<string>", line 15, in __setitem__
TypeError: Invalid type for dictionary value: expected "Number", got "str"
>>> d['foo'] = 5
>>> d['foo']
5

注意,您不需要对传递给super().__init__()的字典进行类型检查UserDict.__init__调用self.__setitem__,您已经覆盖了它,因此如果您将无效的字典传递给TypeCheckedDict.__init__,您会发现异常的引发方式与在构造字典后尝试向字典添加无效键或值的方式相同:

>>> from numbers import Number
>>> d = TypeCheckedDict(str, Number, {'foo': 'bar'})
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "<string>", line 5, in __init__
  line 985, in __init__
    self.update(dict)
  line 842, in update
    self[key] = other[key]
  File "<string>", line 16, in __setitem__
TypeError: Invalid type for dictionary value: expected "Number", got "str"

UserDict是专门为方便以这种方式进行子类化而设计的,这就是为什么在这个实例中它是比dict更好的基类

如果要向TypeCheckedDict添加类型提示,可以这样做:

from collections import UserDict
from collections.abc import Mapping, Hashable
from typing import TypeVar, Optional

K = TypeVar('K', bound=Hashable)
V = TypeVar('V')

class TypeCheckedDict(UserDict[K, V]):
    def __init__(
        self, 
        key_type: type[K], 
        value_type: type[V], 
        initdict: Optional[Mapping[K, V]] = None
    ) -> None:
        self._key_type = key_type
        self._value_type = value_type
        super().__init__(initdict)

    def __setitem__(self, key: K, value: V) -> None:
        if not isinstance(key, self._key_type):
            raise TypeError(
                f'Invalid type for dictionary key: '
                f'expected "{self._key_type.__name__}", '
                f'got "{type(key).__name__}"'
            )
        if not isinstance(value, self._value_type):
            raise TypeError(
                f'Invalid type for dictionary value: '
                f'expected "{self._value_type.__name__}", '
                f'got "{type(value).__name__}"'
            )
        super().__setitem__(key, value)

(上文passes MyPy。)

但是,请注意,添加类型提示与此数据结构在运行时的工作方式完全无关


(2)类型提示词典“用于灵活数量的任意键”


我不太清楚这是什么意思,但如果希望MyPy在向字典添加字符串值时引发错误,则只希望有数值,you could do it like this

from typing import SupportsFloat

d: dict[str, SupportsFloat] = {}
d['a'] = 5  # passes MyPy 
d['b'] = 4.67 # passes MyPy
d[5] = 6 # fails MyPy
d['baz'] = 'foo' # fails Mypy 

如果您想要MyPy静态检查运行时检查,您最好(在我看来)使用上面的TypeCheckedDict类型提示版本:

d = TypeCheckedDict(str, SupportsFloat) # type: ignore[misc]
d['a'] = 5  # passes MyPy 
d['b'] = 4.67  # passes MyPy 
d[5] = 6  # fails Mypy 
d['baz'] = 'foo'  # fails Mypy

Mypy对我们将抽象类型作为参数传递给TypeCheckedDict.__init__不太满意,因此在实例化dict时必须添加一个# type: ignore[misc](这对我来说就像是一个Mypy bug。)但是,除此之外,it works fine

(有关使用SupportsFloat提示数字类型的注意事项,请参见我的previous answer。如果使用Python<;=3.8,则使用typing.Dict而不是dict提示类型。)


(3)使用typeguard

由于您使用的是typeguard,因此可以稍微简化我的StrNumberDict类中的逻辑,如下所示:

from collections import UserDict
from typeguard import typechecked
from typing import SupportsFloat

class StrNumberDict(UserDict[str, SupportsFloat]):
    @typechecked
    def __setitem__(self, key: str, value: SupportsFloat) -> None:
        super().__setitem__(key, value)

但是,如果您想要一个更通用的TypeCheckedDict可以通过任意类型检查进行实例化,我认为没有办法通过typeguard实现这一点。以下不起作用:

### THIS DOES NOT WORK ###

from typing import TypeVar, SupportsFloat
from collections.abc import Hashable
from collections import UserDict
from typeguard import typechecked

K = TypeVar('K', bound=Hashable)
V = TypeVar('V')

class TypeCheckedDict(UserDict[K, V]):
    @typechecked
    def __setitem__(self, key: K, value: V) -> None:
        super().__setitem__(key, value)

d = TypeCheckedDict[str, SupportsFloat]()
d[5] = 'foo'  # typeguard raises no error here.

还值得注意的是,typeguard是not currently maintained,因此使用该特定库会涉及一定的风险

很好的建议,但我观察到它比所有这些都简单得多@亚历克斯·韦古德(alex waygood)有一些很好的解释,即使像我一样,你发现他的解决方案有点过火,他的答案也更正确

class HomebrewTypeGuarding:
    """"Class mimics the primary interface of a dict, as an example
        of naive guarding using the __annotations__ collection that
        exists on any type-annotated object.
    """
    a: str
    b: int
    def __getitem__(self, key):
        return (
            self.__dict__[key] 
            if key in self.__dict__ 
            else None
        )
    def __setitem__(self, key, value):
        if (
            key in self.__annotations__
            and isinstance(value, self.__annotations__[key])
        ):
            self.__dict__[key] = value
        else:
            raise ValueError(
                "Incorrect type for value on {}:{} = {}".format(
                    key,
                    str(
                        self.__annotations__[key] 
                        if key in self.__annotations__
                        else None
                    ),
                    str(value)
                )
            )
        

这里的管道要简单得多,但请注意,我并不是说这是一个命令。我的意图是

  1. 使用注释集合简单演示类型保护,以及
  2. 这样做的方式可以远远超出口述

如果你真的只是在读一篇经过打字检查的口述,那么,@alex waygood将在这里详细介绍,他的解决方案实际上是完整和正确的

如果您对运行时类型检查更感兴趣,在属性或属性分配时,我相信这个解决方案是优越的,因为它实际上可以可靠地发生,对于许多类型,更正确和完整的选项是A,将被复制粘贴到您的所有用例实现中,或者B,转换为元类或装饰器,修改getitem和setitem dunders,成为下面的某种形式

注意,对于B,许多可能的用例具有不同的需求@s-m-e指定了dict,它不能直接用作dict的包装器/装饰器,但可以很容易地进行调整。我更希望您为此调用修饰类/实例上的原始getitem/setitem dunders,而不是直接修改self.__dict__self集合。我想要的是使用annotations对象进行类型检查的最简单示例。如果编写安装(或重写)getitem/setitem对的类或实例修饰符,并且如果这些Dunder只是读取/修改self.__dict__,则会中断DICT。如果您遵守此免责声明,并写出super().__getitem__(in_key),那么对于继承遍历的常见问题,第一代类(例如,此类装饰器的某些实现仍然失败)和多重继承存在边缘情况。当您解决这个问题时,您几乎肯定在类型上使用了getattrs,并为相关的__getitem____setitem__方法或属性探索了多个并行路径

相关问题 更多 >