在运行时验证Python TypedDict

问题描述 投票:0回答:6

我正在 Python 3.8+ Django/Rest-Framework 环境中工作,在新代码中强制执行类型,但构建在许多无类型的遗留代码和数据之上。我们广泛使用 TypedDicts 来确保我们生成的数据以正确的数据类型传递到 TypeScript 前端。

MyPy/PyCharm/等。在检查我们的新代码是否输出符合要求的数据方面做得很好,但我们想测试我们的许多 RestSerializers/ModelSerializers 的输出是否符合 TypeDict。如果我有一个序列化器并输入如下字典:

class PersonSerializer(ModelSerializer):
    class Meta:
        model = Person
        fields = ['first', 'last']

class PersonData(TypedDict):
    first: str
    last: str
    email: str

然后运行如下代码:

person_dict: PersonData = PersonSerializer(Person.objects.first()).data

静态类型检查器无法发现

person_dict
缺少所需的
email
键,因为(根据 PEP-589 的设计)它只是一个普通的
dict
。但我可以写这样的东西:

annotations = PersonData.__annotations__
for k in annotations:
    assert k in person_dict  # or something more complex.
    assert isinstance(person_dict[k], annotations[k])

并且它会发现序列化器的数据中缺少

email
。在这种情况下,这很好,我没有
from __future__ import annotations
引入的任何更改(不确定这是否会破坏它),并且我的所有类型注释都是裸类型。但如果
PersonData
的定义如下:

class PersonData(TypedDict):
    email: Optional[str]
    affiliations: Union[List[str], Dict[int, str]]

然后

isinstance
不足以检查数据是否通过(因为“下标泛型不能与类和实例检查一起使用”)。

我想知道是否已经存在一个可调用函数/方法(在 mypy 或另一个检查器中),它允许我根据注释验证 TypedDict (甚至是单个变量,因为我可以自己迭代字典)并且看看是否有效?

我不关心速度等,因为这样做的目的是检查一次所有数据/方法/函数,然后在我们对当前数据验证感到满意后删除检查。

django-rest-framework mypy python-typing typeddict
6个回答
10
投票

我发现最简单的解决方案可以使用 pydantic。

pydantic v2 的解决方案

import pydantic

from pydantic import TypeAdapter, ValidationError
from typing_extensions import TypedDict # Required by pydantic for python < 3.12

class SomeDict(TypedDict):
    val: int
    name: str
    
SomeDictValidator = TypeAdapter(SomeDict)

# this could be a valid/invalid declaration
obj: SomeDict = {
    'val': 12,
    'name': 'John',
}

# validate with pydantic
try:
    obj = SomeDictValidator.validate_python(obj)
except ValidationError as exc: 
    print(f"ERROR: Invalid schema: {exc}")

请参阅

TypeAdapter
文档了解更多信息。

pydantic v1 的解决方案

from typing import cast, TypedDict 

import pydantic

class SomeDict(TypedDict):
    val: int
    name: str

# this could be a valid/invalid declaration
obj: SomeDict = {
    'val': 12,
    'name': 'John',
}

# validate with pydantic
try:
    obj = cast(SomeDict, pydantic.create_model_from_typeddict(SomeDict)(**obj).dict())
except pydantic.ValidationError as exc: 
    print(f"ERROR: Invalid schema: {exc}")

编辑:当类型检查时,它当前返回一个错误,但按预期工作。请参阅此处:https://github.com/samuelcolvin/pydantic/issues/3008


2
投票

您可能想看看https://pypi.org/project/strongtyping/。这可能会有所帮助。

在文档中您可以找到此示例:

from typing import List, TypedDict

from strongtyping.strong_typing import match_class_typing


@match_class_typing
class SalesSummary(TypedDict):
    sales: int
    country: str
    product_codes: List[str]

# works like expected
SalesSummary({"sales": 10, "country": "Foo", "product_codes": ["1", "2", "3"]})

# will raise a TypeMisMatch
SalesSummary({"sales": "Foo", "country": 10, "product_codes": [1, 2, 3]})

0
投票

有点破解,但您可以使用 mypy 命令行

-c
选项检查两种类型。只需将其包装在 python 函数中即可:

import subprocess

def is_assignable(type_to, type_from) -> bool:
    """
    Returns true if `type_from` can be assigned to `type_to`,
    e. g. type_to := type_from

    Example:
    >>> is_assignable(bool, str) 
    False
    >>> from typing import *
    >>> is_assignable(Union[List[str], Dict[int, str]], List[str])
    True
    """
    code = "\n".join((
        f"import typing",
        f"type_to: {type_to}",
        f"type_from: {type_from}",
        f"type_to = type_from",
    ))
    return subprocess.call(("mypy", "-c", code)) == 0

0
投票

你可以这样做:

def validate(typ: Any, instance: Any) -> bool:
    for property_name, property_type in typ.__annotations__.items():
        value = instance.get(property_name, None)
        if value is None:
            # Check for missing keys
            print(f"Missing key: {property_name}")
            return False
        elif property_type not in (int, float, bool, str):
            # check if property_type is object (e.g. not a primitive)
            result = validate(property_type, value)
            if result is False:
                return False
        elif not isinstance(value, property_type):
            # Check for type equality
            print(f"Wrong type: {property_name}. Expected {property_type}, got {type(value)}")
            return False
    return True

然后测试一些对象,例如传递到您的 REST 端点的一个:

class MySubModel(TypedDict):
    subfield: bool


class MyModel(TypedDict):
    first: str
    last: str
    email: str
    sub: MySubModel

m = {
    'email': 'JohnDoeAtDoeishDotCom',
    'first': 'John'
}
assert validate(MyModel, m) is False

这个打印第一个错误并返回布尔值,您可以将其更改为异常,可能会丢失所有键。您还可以将其扩展为在模型定义之外的其他键上失败。


0
投票

我会使用

typing.get_type_hints
函数,它从
TypeDict
返回一个字典(在 python 3.8 下测试):

from typing import TypedDict, get_type_hints

def checkdict(value: object, typedict: type) -> None:
    """
    Raise a TypeError if value does not check the TypeDict.
    :param value: the value to check
    :param typedict: the TypeDict type
    """
    if not isinstance(value, dict):
        raise TypeError(f'Value must be a dict not a: {type(value).__name__}')
    d = get_type_hints(typedict)
    diff = d.keys() ^ value.keys()
    if diff: # must have the same fields
        raise TypeError(f"Invalid dict fields: {' '.join(diff)}")
    for k, v in get_type_hints(typedict).items():
        if not isinstance(value[k], v): # must have same types
            raise TypeError(
                f"Invalid type: '{k}' should be {v.__name__} "
                f"but is {type(value[k]).__name__}"
            )

class TargetDict(TypedDict):
    name: str
    integer: int

obj: dict = {
    'name': 'John',
    'integer': '3',
}

checkdict(
    obj, TargetDict
)  # TypeError: Invalid type: 'integer' should be int but is str

0
投票

我喜欢你的解决方案!为了避免某些用户的迭代修复,我在您的解决方案中添加了一些代码:D

def validate_custom_typed_dict(instance: Any, custom_typed_dict:TypedDict) -> bool|Exception:
    key_errors = []
    type_errors = []
    for property_name, type_ in my_typed_dict.__annotations__.items():
        value = instance.get(property_name, None)
        if value is None:
            # Check for missing keys
            key_errors.append(f"\t- Missing property: '{property_name}' \n")
        elif type_ not in (int, float, bool, str):
            # check if type is object (e.g. not a primitive)
            result = validate_custom_typed_dict(type_, value)
            if result is False:
                type_errors.append(f"\t- '{property_name}' expected {type_}, got {type(value)}\n")
        elif not isinstance(value, type_):
            # Check for type equality
            type_errors.append(f"\t- '{property_name}' expected {type_}, got {type(value)}\n")

    if len(key_errors) > 0 or len(type_errors) > 0:
        error_message = f'\n{"".join(key_errors)}{"".join(type_errors)}'
        raise Exception(error_message)
    
    return True

一些控制台输出:

Exception: 
        - Missing property: 'Combined_cycle' 
        - Missing property: 'Solar_PV' 
        - Missing property: 'Hydro' 
        - 'timestamp' expected <class 'str'>, got <class 'int'>
        - 'Diesel_engines' expected <class 'float'>, got <class 'int'>
© www.soinside.com 2019 - 2024. All rights reserved.