编写使用pydantic在运行时应用类型信息的Python代码


本文是Python第2部分2020年出现日历,第16天。

类型提示是在Python 3.5中引入的,现在,即使在最初是动态类型化语言的Python中,也通常在代码中写入类型信息。

这次,我将介绍pydantic,这是一个充分利用此类型信息的库,可以极大地帮助您编写更强大的Python代码。

什么是pydantic?

由于最近流行的Python网络框架FastAPI中也使用了它,因此许多人可能知道它的存在。
实际上,当我第一次使用Fast API时,我也了解了pydantic的存在。

pydantic这是一个实现以下功能的库。

  • 提供运行时类型信息

  • 返回不正确数据的用户友好错误

很多人说这就是他们所需要的。
在此之后,我将使用一个示例进行说明。

官方资源

GitHub:samuelcolvin / pydantic:使用Python类型提示进行数据解析和验证
官方文档:pydantic

示例

pydantic在从基类pydantic.BaseModel继承的用户定义的类中效果很好。

首先,让我们考虑一个不使用pydantic的类定义。
使用dataclasses.dataclass

1
2
3
4
5
6
7
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class NonPydanticUser:
    name: str
    age: int

让我们创建这个NonPydanticUser类的一个实例。
在此示例中,两个字段name的类型为str,而age的类型为int
它保存类中定义的数据类型。

1
2
3
4
5
6
7
Ichiro = NonPydanticUser(name="Ichiro", age=19)
print(Ichiro)
#> NonPydanticUser(name='Ichiro', age=19)
print(type(Ichiro.name))
#> <class 'str'>
print(type(Ichiro.age))
#> <class 'int'>

让我们创建另一个实例。

1
2
3
4
5
6
7
Samatoki = NonPydanticUser(name="Samatoki", age="25")
print(Samatoki)
#> NonPydanticUser(name='Samatoki', age='25')
print(type(Samatoki.name))
#> <class 'str'>
print(type(Samatoki.age))
#> <class 'str'>

在此示例中,name的类型为str,但是age的类型为str
不会抛出诸如TypeError之类的异常。

您可以再次看到类型注释提供的类型信息仅在编码时有效。
当然,如果使用mypyPylance,则可以在编码时检测到这种类型不一致的情况,但是如果由于代码执行时类型不一致或值无效而要抛出异常,请检查输入自己创造价值,必须做到。

另一方面,使用pydantic的类定义如下。

1
2
3
4
5
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

乍一看,它类似于使用dataclasses.dataclass
但是有明显的区别。

首先,让我们使用常规字段值创建一个实例。

1
2
3
4
5
6
7
Ramuda = User(name="Ramuda", age=24)
print(Ramuda)
#> name='Ramuda' age=24
print(type(Ramuda.name))
#> <class 'str'>
print(type(Ramuda.age))
#> <class 'int'>

仅凭这一点您无法真正分辨出区别。
接下来,给age一个str类型编号,例如"23""45"

1
2
3
4
5
6
Jakurai = User(name="Jakurai", age="35")
#> name='Jakurai' age=35
print(type(Jakurai.name))
#> <class 'str'>
print(type(Jakurai.age))
#> <class 'int'>

Jakurai.age强制转换为类型int

顺便说一句,如果给age一个不能转换为int类型(例如hogefuga)的值,会发生什么?

1
2
3
4
Sasara = User(name="Sasara", age="ホンマか?")
#> ValidationError: 1 validation error for User
#> age
#>   value is not a valid integer (type=type_error.integer)

引发了

ValidationError异常。
即使未特别执行验证,我仍检测到无效值。

以这种方式使用pydantic时,不仅在编码时而且在执行代码时都应用了所描述的类型信息,并且它为无效值抛出了一个易于理解的异常(后述),因此您可以使用Python(一种打字语言)为类型编写严格的代码!

pydantic推荐给这样的人! !!

  • 我想尽可能地省略简单的验证
  • 从Go,TypeScript和Swift等类型严格的语言进入Python并希望关心Python中的类型的人
  • 任何想要编写健壮代码的人
  • 任何想要被绑在模具上的人

pydantic基本知识

我将使用官方示例的以下代码进行基本说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel


class User(BaseModel):
    id: int
    name = 'John Doe'
    signup_ts: Optional[datetime] = None
    friends: List[int] = []


external_data = {
    'id': '123',
    'signup_ts': '2019-06-01 12:22',
    'friends': [1, 2, '3'],
}
user = User(**external_data)
print(user.id)
#> 123
print(repr(user.signup_ts))
#> datetime.datetime(2019, 6, 1, 12, 22)
print(user.friends)
#> [1, 2, 3]
print(user.dict())
"""
{
    'id': 123,
    'signup_ts': datetime.datetime(2019, 6, 1, 12, 22),
    'friends': [1, 2, 3],
    'name': 'John Doe',
}
"""

继承基类

pydantic.BaseModel并定义您自己的类。
在此类定义中,定义了四个字段:idnamesignup_tsfriends
每个字段都有不同的描述。根据文档,它具有以下含义。

  • id(int)...如果仅声明了类型提示,则它将是必填字段。如果在实例化时给出类型为strbytesfloat的值,则将其强制转换为int。如果给出任何其他数据类型(dictlist等)的值,则将引发异常。

  • 从默认值name(str)... John Doe,可以推断namestr类型。另外,name不是必需字段,因为声明了默认值。

  • signup_ts:(datetime,可选)...允许Nonedatetime类型。另外,sign_up不是必需字段,因为声明了默认值。可以将类型为int的UNIX时间戳(例如1608076800.0)或表示日期和时间的类型为str的字符串作为参数。

  • friends:(List[int])...使用Python的内置键入系统。另外,由于声明了默认值,因此它不是必填字段。与id一样,"123""45"也将转换为int类型。

我提到过,如果您在实例化继承

pydantic.BaseModel的类时尝试给出无效值,则会抛出异常pydantic.ValidationError

让我们使用以下代码在ValidationError中进行查看。

1
2
3
4
5
6
from pydantic import ValidationError

try:
    User(signup_ts='broken', friends=[1, 2, 'not number'])
except ValidationError as e:
    print(e.json())

此代码的ValidationError的内容如下。
您可以看到每个字段中发生了什么样的不一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[
  {
    "loc": [
      "id"
    ],
    "msg": "field required",
    "type": "value_error.missing"
  },
  {
    "loc": [
      "signup_ts"
    ],
    "msg": "invalid datetime format",
    "type": "value_error.datetime"
  },
  {
    "loc": [
      "friends",
      2
    ],
    "msg": "value is not a valid integer",
    "type": "type_error.integer"
  }
]

贴士

仅本文不能介绍所有的pydantic,但是从现在开始,我想介绍可以立即使用的元素。

栏位类型

有许多支持

pydantic的数据类型。
这里是其中的一些。

标准库类型

当然,您可以使用原始数据类型,例如

intstrlistdict
它还支持诸如typingipaddressenumdecimalpathlibuuid之类的内置库。

以下是使用ipadress.IPv4Address的示例。

1
2
3
4
5
6
7
8
9
10
11
12
from pydantic import BaseModel
from ipaddress import IPv4Address

class IPNode(BaseModel):
    address: IPv4Address

client = IPNode(address="192.168.0.12")

srv = IPNode(address="hoge")
#> ValidationError: 1 validation error for IPNode
#> address
#>  value is not a valid IPv4 address (type=value_error.ipv4address)

网址

pydantic也支持诸如https://example.comftp://hogehoge之类的URL。

1
2
3
4
5
6
7
8
9
10
11
from pydantic import BaseModel, HttpUrl, AnyUrl

class Backend(BaseModel):
    url: HttpUrl

bd1 = Backend(url="https://example.com")

bd2 = Backend(url="file://hogehoge")
#> ValidationError: 1 validation error for Backend
#> url
#>  URL scheme not permitted (type=value_error.url.scheme; allowed_schemes={'https', 'http'})

秘密类型

您还可以处理不想输出到输出的信息,例如日志。
例如,您可以使用pydantic.SecretStr作为密码。

1
2
3
4
5
6
7
8
from pydantic import BaseModel, SecretStr

class Password(BaseModel):
    value: SecretStr

p1 = Password(value="hogehogehoge")
print(p1.value)
#> **********

电子邮件Str

一种可以处理电子邮件地址的类型。
但是,要使用它,您需要与pydantic分开安装一个名为email-vaidator的库。

让我们在上一节中使用此EmailStrSecret Types

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from pydantic import BaseModel, EmailStr, SecretStr, Field

class User(BaseModel):
    email: EmailStr
    password: SecretStr = Field(min_length=8, max_length=16)

# OK
Juto = User(email="[email protected]", password="hogehogehoge")
print(Juto)
#> email='[email protected]' password=SecretStr('**********')

# NG, emailがメールアドレスのフォーマットになっていない
Rio = User(email="rio", password="hogehogehogehoge")
#> ValidationError: 1 validation error for User
#> email
#>   value is not a valid email address (type=value_error.email)

# NG, passwordの文字数が16文字を越えている
Gentaro = User(email="[email protected]", password="hogehogehogehogehoge")
#> ValidationError: 1 validation error for User
#> password
#>   ensure this value has at most 16 characters (type=value_error.any_str.max_length; limit_value=16)

# NG, passwordの文字数が8文字未満である
Daisu = User(email="[email protected]", password="hoge")
#> ValidationError: 1 validation error for User
#> password
#>   ensure this value has at least 8 characters (type=value_error.any_str.min_length; limit_value=8)

约束类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pydantic import BaseModel, HttpUrl, AnyUrl, SecretStr, conint

# 正の数だけ許容する様にしてみる
class PositiveNumber(BaseModel):
    value: conint(gt=0)

# OK
n1 = PositiveNumber(value=334)

#NG, 負の数である
n2 = PositiveNumber(value=-100)
#> ValidationError: 1 validation error for PositiveNumber
#> value
#>   ensure this value is greater than 0 (type=value_error.number.not_gt; limit_value=0)

严格类型

在本文开头的示例中,我很感激将str类型的数字(例如"23""45")转换为int类型并接受它们。
您还可以声明更严格的字段,甚至不允许进行此转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pydantic import BaseModel, conint, StrictInt

# キャストを認めないint
class StrictNumber(BaseModel):
    value: StrictInt

# OK
n1 = StrictNumber(value=4)

# キャストしてint型になれるstr型であっても、int型ではないのでNG
n2 = StrictNumber(value="4")
#> ValidationError: 1 validation error for StrictNumber
#> value
#>   value is not a valid integer (type=type_error.integer)

它也可以与上一节中的约束类型组合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from pydantic import BaseModel conint

# 自然数だけ許容する
class NaturalNumber(BaseModel):
    value: conint(strict=True, gt=0)

# OK
n1 = NaturalNumber(value=334)

# NG, 負の数である
n2 = NaturalNumber(value=-45)
#> ValidationError: 1 validation error for NaturalNumber
#> value
#>  ensure this value is greater than 0 (type=value_error.number.not_gt; limit_value=0)

# キャストしてint型になれるstr型であっても、int型ではないのでNG
n3 = NaturalNumber(value="45")
#> ValidationError: 1 validation error for NaturalNumber
#> value
#>   value is not a valid integer (type=type_error.integer)

# float型も許容されない
n4 = NaturalNumber(value=123.4)
#> ValidationError: 1 validation error for NaturalNumber
#> value
#>   value is not a valid integer (type=type_error.integer)

验证者

可以在字段声明时编写简单的验证,但是可以使用pydantic.validator创建用户定义的验证。

基本验证器

考虑一个简单的例子。
定义仅当name字段包含半角空格时才允许的validator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pydantic import BaseModel, validator

# nameに半角スペースが含まれていない場合を許容しない
class User(BaseModel):
    name: str
    age: int

    @validator("name")
    def validate_name(cls, v):
        if ' ' not in v:
            raise ValueError("must contain a space")
        return v

# OK
Jiro = User(name="山田 二郎", age=17)

# NG
Saburo = User(name="山田三郎", age=14)
#> ValidationError: 1 validation error for User
#> name
#>   must contain a space (type=value_error)

实现具有多个字段的验证器

例如,考虑一个Event类,该类将date的开始时间和结束时间分别保存为beginend

1
2
3
4
5
6
7
8
from datetime import datetime
from pydantic import BaseModel

class Event(BaseModel):
    begin: datetime
    end: datetime

event = Event(begin="2020-12-16T09:00:00+09:00", end="2020-12-16T12:00:00+09:00")

这时,我想保证分配给end字段的时间晚于分配给begin字段的时间。
如果beginend的时间匹配,则它也被视为无效值。

我认为有几种方法可以做到。我想介绍两个。
第一种方法是使用pydantic.root_validator而不是pydantic.validator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from datetime import datetime
from pydantic import BaseModel, root_validator

class Event(BaseModel):
    begin: datetime
    end: datetime

    @root_validator(pre=True)
    def validate_event_schedule(cls, values):
        _begin: datetime = values["begin"]
        _end: datetime = values["end"]

        if _begin >= _end:
            raise ValueError("Invalid event.")
        return values

# OK
event1 = Event(begin="2020-12-16T09:00:00+09:00", end="2020-12-16T12:00:00+09:00")

# NG
event2 = Event(begin="2020-12-16T12:00:00+09:00", end="2020-12-16T09:00:00+09:00")
#> ValidationError: 1 validation error for Event
#> __root__
#>  Invalid event. (type=value_error)

# NG
event3 = Event(begin="2020-12-16T12:00:00+09:00", end="2020-12-16T12:00:00+09:00")
#> ValidationError: 1 validation error for Event
#> __root__
#>  Invalid event. (type=value_error)

另一个使用validator的规范。
我将首先介绍代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from datetime import datetime
from pydantic import BaseModel, root_validator, validator

class Event(BaseModel):
    begin: datetime
    end: datetime

    @validator("begin", pre=True)
    def validate_begin(cls, v):
        return v

    @validator("end")
    def validate_end(cls, v, values):
        if values["begin"] >= v:
            raise ValueError("Invalid schedule.")
        return v

此代码定义了两个validator

实例化此Event类时,首先执行设置了参数pre=Truevalidate_begin。在validate_begin中,实例化时在参数begin中指定的值将直接在begin字段中设置。

然后处理validate_end

但是,与validate_begin不同,validate_end具有指定为第三个参数的参数values
作为pydantic.validator的规范,您可以使用第三个参数values访问在某个validator之前执行的validator中的输入值检查字段。
values不能是_valuesvalues。将其视为一种保留字。

换句话说,在此代码的情况下,每个字段的输入值检查的顺序如下。

  • 首先执行validate_beginbegin的输入值检查
  • 之后,validate_end检查end的输入值。此时,您可以在validate_end范围内用values["begin"]引用begin字段。
  • 我们介绍了以上两种方法。请让我知道是否有更好的方法。

    listdictSet等中包含的每个元素的Validator

    考虑一个满足以下规范的RepeatedExams类。

    • 它具有List[int]类型字段scores,该字段恰好存储10个测试分数(int类型)。
    • 每个测试结果必须至少为50分。
    • 10个测试结果的总分必须达到800分或以上。

    代码如下。
    如果要为listdictSet等类型的字段的每个元素按某个validator检查输入值,请将each_item=True设置为该validator
    在下面的代码中,为名为validate_each_scorevalidator设置了each_item=True

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    from pydantic import BaseModel
    from typing import List

    class RepeatedExams(BaseModel):
        scores: List[int]

        # 試験結果の回数がちょうど10回であるか検証
        @validator("scores", pre=True)
        def validate_num_of_exams(cls, v):
            if len(v) != 10:
                raise ValueError("The number of exams must be 10.")
            return v

        # 1回の試験結果が50点以上であるか検証
        @validator("scores", each_item=True)
        def validate_each_score(cls, v):
            assert v >= 50, "Each score must be at least 50."
            return v

        # 試験結果の合計が800点以上であるか検証
        @validator("scores")
        def validate_sum_score(cls, v):
            if sum(v) < 800:
                raise ValueError("sum of numbers greater than 800")
            return v

    # OK
    result1 = RepeatedExams(scores=[87, 88, 77, 100, 61, 59, 97, 75, 80, 85])

    # NG, 9回しか試験を受けていない
    result2 = RepeatedExams(scores=[87, 88, 77, 100, 61, 59, 97, 75, 80])
    #> ValidationError: 1 validation error for RepeatedExams
    #> scores
    #>   The number of exams must be 10. (type=value_error)

    # NG, 50点未満の試験がある
    result3 = RepeatedExams(scores=[87, 88, 77, 100, 32, 59, 97, 75, 80, 85])
    #> ValidationError: 1 validation error for RepeatedExams
    #> scores -> 4
    #>   Each score must be at least 50. (type=assertion_error)


    # NG, 10回の試験の合計が800点未満である
    result4 = RepeatedExams(scores=[87, 88, 77, 100, 51, 59, 97, 75, 80, 85])
    #> ValidationError: 1 validation error for RepeatedExams
    #> scores
    #>   sum of numbers greater than 800 (type=value_error)

    导出模型

    pydantic.BaseModel继承的类的实例可以转换为字典或JSON格式,并且可以复制。
    您不仅可以转换和复制,还可以指定目标字段并仅输出特定字段。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    from pydantic import BaseModel, conint

    class User(BaseModel):
        name: str
        age: conint(strict=True, ge=0)
        height: conint(strict=True, ge=0)
        weight: conint(strict=True, ge=0)

    Kuko = User(name="Kuko", age=19, height=168, weight=58)
    print(Kuko)

    # 全フィールドを対象にdictに変換
    Kuko_dict_1 = Kuko.dict()
    print(Kuko_dict_1)
    #> {'name': 'Kuko', 'age': 19, 'height': 168, 'weight': 58}

    # nameだけを対象にdictに変換
    Kuko_name = Kuko.dict(include={"name"})
    print(Kuko_name)
    #> {'name': 'Kuko'}

    # 全フィールドを対象にコピー
    print(Kuko.copy())
    print(Kuko_2)
    #> name='Kuko' age=19 height=168 weight=58

    # ageだけ除外してコピー
    Kuko_3 = Kuko.copy(exclude={"age"})
    print(Kuko_3)
    #> name='Kuko' height=168 weight=58

    # 全フィールドを対象にJSONに
    Kuko_json = Kuko.json()
    print(Kuko_json)
    #> {"name": "Kuko", "age": 19, "height": 168, "weight": 58}
    print(type(Kuko_json))
    #> <class 'str'>

    末尾

    我放弃了诸如Model Config和Schema之类的其他元素,因为我没有足够的时间来编写它们。
    我希望以后可以添加它...