▌簡介
-
到目前為止,老師所有範例的模型欄位,都是 Python 類型或註解類型。
但其實 Pydantic 的模型欄位,也可以是 Pydantic。(範例:通訊錄中 聯絡人 的 電話:市話和手機)
Pydantic 的模型欄位,也可以是自訂類別;但考慮到要自行撰寫符合 Pydantic 規格的序列化和反序列化工作,太過於複雜,老師強烈建議我們不要自找麻煩。
與其千苦萬苦的自訂類別,倒不如使用 模型組合 來達成同樣的目的。
-
到目前為止,老師所有範例,都是繼承自 BaseModel。
如果你有個整套系統都會用到的通用模型配置。與其每個模型都寫一樣的程式,不如一開始就寫個 myBaseModel(當然也是繼承自 BaseModel),然後讓所有的模型都繼承 myBaseModel 而不是 BaseModel。
不建議 multiple inheritance 多重繼承。老師以自己之前的痛苦經驗,勸你不要想不開。
那麼應該選擇 模型組合 還是 繼承 呢?
繼承 相對而言較難除錯,大部分的情況下, 模型組合 會是較好的選擇。
▌Model Composition 模型組合
簡單的說,就是 Pydantic models 裡面的 fields 也是 Pydantic models。
Python 原本就有組合,但 Pydantic 模型組合的特點在:Model fields 可以序列化和反序列化。
第一個是萬年範例:圓的中心點、半徑、面積。
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class Point2D(BaseModel):
x: float = 0
y: float = 0
class Circle2D(BaseModel):
center: Point2D
radius: float = Field(default=1, gt=0)
c = Circle2D(center=Point2D(x=1, y=1), radius=2)
# 一樣把簡短輸入輸出寫在一起,方便閱讀
c
c.model_dump()
c.model_dump_json()
Circle2D(center=Point2D(x=1.0, y=1.0), radius=2.0)
{'center': {'x': 1.0, 'y': 1.0}, 'radius': 2.0}
{"center":{"x":1.0,"y":1.0},"radius":2.0}
和以前一樣,我們可以直接以 dict/JSON 方式賦值。
以 dict/JSON 方式賦值:
data = {
"center": {
"x": 5,
"y": -5
},
"radius": 10
}
c = Circle2D.model_validate(data)
c
Circle2D(center=Point2D(x=5.0, y=-5.0), radius=10.0)
data = """
{
"center": {
"x": 5,
"y": -5
},
"radius": 10
}
"""
c = Circle2D.model_validate_json(data)
c
Circle2D(center=Point2D(x=5.0, y=-5.0), radius=10.0)
同樣的,我們也可以用點字符 .
來存取欄位中的值:
c.center
c.center.x
Point2D(x=5.0, y=-5.0)
5.0
模型組合(Model Composition)的其中一個好處在於:每個模型都可以獨立配置。
也就是說,在組合多個模型來構建複雜數據結構時,每個模型有各自獨立的配置,這些配置不會互相影響。
例如許多網站提供的 API,內容可能十分龐大,但我們只需要其中少數的部分內容,這時上述特點就顯得十分有用。
例如:udemy API 2.0 提供了一大堆課程資訊,但我們論壇只需要這些:
url_API = URL_API2 + str(
courseID) +
"/?fields[course]=
title,
headline,
primary_category,
primary_subcategory,
context_info,
content_info_short,
published_time,
last_update_date,
num_subscribers,
locale,
has_closed_caption,
rating,
num_reviews,
price,
url,
image_240x135"
老師的範例,原始 JSON 資料來源如下:
json_data = """
{
"firstName": "David",
"lastName": "Hilbert",
"contactInfo": {
"email": "d.hilbert@spectral-theory.com",
"homePhone": {
"countryCode": 49,
"areaCode": 551,
"localPhoneNumber": 123456789
}
},
"personalInfo": {
"nationality": "German",
"born": {
"date": "1862-01-23",
"place": {
"city": "Konigsberg",
"country": "Prussia"
}
},
"died": {
"date": "1943-02-14",
"place": {
"city": "Gottingen",
"country": "Germany"
}
}
},
"awards": ["Lobachevsky Prize", "Bolyai Prize", "ForMemRS"],
"notableStudents": ["von Neumann", "Weyl", "Courant", "Zermelo"]
}
"""
假設我們只對名字、姓氏、電子郵件、出生日、獎項和著名學生感興趣,我們可以用本節所談的 模型組合 方式來處理。
from pydantic import EmailStr, PastDate
from pydantic.alias_generators import to_camel
class ContactInfo(BaseModel):
model_config = ConfigDict(extra="ignore")
email: EmailStr | None = None
class PlaceInfo(BaseModel):
city: str
country: str
class PlaceDateInfo(BaseModel):
model_config = ConfigDict(populate_by_name=True)
date_: PastDate = Field(alias="date")
place: PlaceInfo
class PersonalInfo(BaseModel):
model_config = ConfigDict(extra="ignore")
nationality: str
born: PlaceDateInfo
class Person(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="ignore")
first_name: str
last_name: str
contact_info: ContactInfo
personal_info: PersonalInfo
notable_students: list[str] = []
p = Person.model_validate_json(json_data)
p
print(p.model_dump_json(by_alias=True, indent=2))
Person(first_name='David', last_name='Hilbert', contact_info=ContactInfo(email='d.hilbert@spectral-theory.com'), personal_info=PersonalInfo(nationality='German', born=PlaceDateInfo(date_=datetime.date(1862, 1, 23), place=PlaceInfo(city='Konigsberg', country='Prussia'))), notable_students=['von Neumann', 'Weyl', 'Courant', 'Zermelo'])
{
"firstName": "David",
"lastName": "Hilbert",
"contactInfo": {
"email": "d.hilbert@spectral-theory.com"
},
"personalInfo": {
"nationality": "German",
"born": {
"date": "1862-01-23",
"place": {
"city": "Konigsberg",
"country": "Prussia"
}
}
},
"notableStudents": [
"von Neumann",
"Weyl",
"Courant",
"Zermelo"
]
}
作為練習,假設我們要將知名學生 notableStudents
顯示時以字母順序排序,我們可以這樣做:
from typing import Annotated
from pydantic import AfterValidator
+ # using an AfterValidator, so guaranteed value will be a list (empty or strings)
+ SortedStringList = Annotated[list[str], AfterValidator(lambda value: sorted(value, key=str.casefold))]
class Person(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="ignore")
first_name: str
last_name: str
contact_info: ContactInfo
personal_info: PersonalInfo
notable_students: SortedStringList = []
再來看一次結果:
p = Person.model_validate_json(json_data)
p
print(p.model_dump_json(by_alias=True, indent=2))
Person(first_name='David', last_name='Hilbert', contact_info=ContactInfo(email='d.hilbert@spectral-theory.com'), personal_info=PersonalInfo(nationality='German', born=PlaceDateInfo(date_=datetime.date(1862, 1, 23), place=PlaceInfo(city='Konigsberg', country='Prussia'))), notable_students=['Courant', 'von Neumann', 'Weyl', 'Zermelo'])
{
"firstName": "David",
"lastName": "Hilbert",
"contactInfo": {
"email": "d.hilbert@spectral-theory.com"
},
"personalInfo": {
"nationality": "German",
"born": {
"date": "1862-01-23",
"place": {
"city": "Konigsberg",
"country": "Prussia"
}
}
},
"notableStudents": [
"Courant",
"von Neumann",
"Weyl",
"Zermelo"
]
}
排序的功能很常用到。以我在 udemy API 取得的 JSON 資料為例,我會根據語言別和評分排序,由程式自動產生每次要上傳論壇的限免課程內容。(我目前是以 pandas dataframe 讀入爬蟲爬回來的 csv 檔作處理)
另外,我很好奇 ChatGPT 和 Claude 會用什麼方式(畢竟我現在常常請他們協助寫程式),來達成這個排序的目標,以下是他們各自的作法。
ChatGPT 是在 Person 中,以 __init__
處理。
def __init__(self, **data):
super().__init__(**data)
# self.notable_students.sort()
self.notable_students = sorted(self.notable_students, key=str.lower)
Claude 則是:
-
在
Person
類中,我們將notable_students
欄位定義為Field
,並使用default_factory
參數設置為一個返回空列表的 lambda 函數。 -
我們重寫了
model_validate_json
類方法。在這個方法中,我們首先調用父類的model_validate_json
方法來創建實例,然後對notable_students
列表進行排序,最後返回修改後的實例。
這樣,每當從 JSON 數據創建 Person
實例時,notableStudents
列表都會自動按字母順序排序。
notable_students: list[str] = Field(default_factory=lambda: [])
@classmethod
def model_validate_json(cls, json_data: str, *args, **kwargs):
instance = super().model_validate_json(json_data, *args, **kwargs)
# instance.notable_students.sort() # 排序 notable_students
instance.notable_students.sort(key=str.lower) # 使用 str.lower 作為 key 來排序
return instance
感覺老師的寫法比較漂亮,當我把老師的寫法提供給 Claude 之後…
這是一個非常優雅的解決方案!你的方法使用了 Pydantic 的 AfterValidator
和 Python 的類型註解系統,這確實是一個更好的實現。讓我解釋一下這個方法的優點:
-
使用
AfterValidator
:
這確保了每次notable_students
被賦值時都會自動排序,不僅僅是在模型創建時。 -
SortedStringList
類型註解:
這創建了一個自定義類型,可以在多個地方重用,使代碼更加模塊化和可維護。 -
使用
str.casefold
而不是str.lower
:
casefold
提供了更強大的大小寫轉換,特別是對於某些特殊的 Unicode 字符。 -
簡化了
Person
類:
不需要重寫model_validate_json
方法,使類定義更加簡潔。
這個實現方式有以下優勢:
-
更加聲明式:通過類型註解清晰地表達了字段的行為。
-
更加靈活:可以輕易地在其他模型中重用
SortedStringList
。 -
自動化:無論何時賦值給
notable_students
,都會自動排序。 -
類型安全:提供了更好的類型提示,有利於 IDE 的自動完成和類型檢查。
使用這種方法,每次 notable_students
被賦值或修改時,它都會自動按照不區分大小寫的字母順序排序。這包括模型初始化、JSON 驗證、甚至直接賦值操作。
這是一個非常好的實現方式,它充分利用了 Pydantic 的高級特性,提供了一個優雅且可重用的解決方案。非常好的改進!
Python composition
▌Model Inheritance 模型繼承
重複使用
模型繼承一個很大的優點是可重複使用。
通常我們會設計一個標準化的客製化基礎模型,作為被繼承的基礎類別。
假設我們想讓模型都具備以下的功能:
-
忽略額外字段
-
使用自動產生的別名
-
允許按名稱和別名填充
例如以下範例中的 CustomBaseModel
:
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class CustomBaseModel(BaseModel):
model_config = ConfigDict(
extra="ignore",
alias_generator=to_camel,
populate_by_name=True
)
現在讓我們把這個 CustomBaseModel
,運用到前一節中的程式,取代原先的 BaseModel
(JSON 資料不變):
from typing import Annotated
from pydantic import AfterValidator, EmailStr, Field, PastDate
SortedStringList = Annotated[list[str], AfterValidator(lambda value: sorted(value, key=str.casefold))]
class ContactInfo(CustomBaseModel):
email: EmailStr | None = None
class PlaceInfo(CustomBaseModel):
city: str
country: str
class PlaceDateInfo(CustomBaseModel):
date_: PastDate = Field(alias="date")
place: PlaceInfo
class PersonalInfo(CustomBaseModel):
nationality: str
born: PlaceDateInfo
class Person(CustomBaseModel):
# model_config = ConfigDict(extra="allow") # 會秀 awards 資料
first_name: str
last_name: str
contact_info: ContactInfo
personal_info: PersonalInfo
notable_students: SortedStringList = []
p = Person.model_validate_json(json_data)
print(p.model_dump_json(by_alias=True, indent=2))
{
"firstName": "David",
"lastName": "Hilbert",
"contactInfo": {
"email": "d.hilbert@spectral-theory.com"
},
"personalInfo": {
"nationality": "German",
"born": {
"date": "1862-01-23",
"place": {
"city": "Konigsberg",
"country": "Prussia"
}
}
},
"notableStudents": [
"Courant",
"von Neumann",
"Weyl",
"Zermelo"
]
}
讓所有模型都包含某些欄位
例如,建立 REST API 時,希望 API 的每個回應都包含有關請求的一些基本資訊:唯一 ID、發出請求的日期和時間,執行所需的時間…。
注意!不要受到多重繼承的誘惑,除非你是多重繼承方面的專家,並且知道 Pydantic 支援你打算使用的方式,否則會遇到問題(老師以自己過去的經歷說明)。
我們希望所有模型的配置都相同,但並非應用程式中的所有模型,都需要標準請求資訊欄位。
以下是把這些東西分開處理的範例。
class CustomBaseModel(BaseModel):
model_config = ConfigDict(
extra="ignore",
alias_generator=to_camel,
populate_by_name=True
)
CustomBaseModel
的部分仍然一樣,另外新建 註釋類型 來處理 datetime,以及我們為日期時間物件實現的 JSON 序列化:
from datetime import datetime
from typing import Any
import pytz
from dateutil.parser import parse
from pydantic import AfterValidator, BeforeValidator, FieldSerializationInfo, field_serializer, PlainSerializer
def make_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
dt = pytz.utc.localize(dt)
else:
dt = dt.astimezone(pytz.utc)
return dt
def parse_datetime(value: Any):
if isinstance(value, str):
try:
return parse(value)
except Exception as ex:
raise ValueError(str(ex))
return value
def dt_serializer(dt, info: FieldSerializationInfo) -> datetime | str:
if info.mode_is_json():
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
return dt
DateTimeUTC = Annotated[
datetime,
BeforeValidator(parse_datetime),
AfterValidator(make_utc),
PlainSerializer(dt_serializer, when_used="unless-none")
]
上述程式在我們之前的章節提過,如果不太記得,點擊本行觀看說明:
自定義日期時間類型 DateTimeUTC
,結合了多個 Pydantic 驗證器和序列化器,用於處理 UTC 時間。
-
make_utc
函數:- 將給定的
datetime
物件轉換為 UTC 時間。 - 如果輸入的日期時間沒有時區信息,假定它為 UTC 時間。
- 如果有時區信息,則將其轉換為 UTC。
- 將給定的
-
parse_datetime
函數:- 嘗試將輸入值解析為
datetime
物件。 - 如果輸入是字符串,使用
dateutil.parser.parse
進行解析。 - 如果解析失敗,拋出
ValueError
。 - 如果輸入不是字符串,則直接返回。
- 嘗試將輸入值解析為
-
dt_serializer
函數:- 定義如何序列化
datetime
物件。 - 如果序列化模式是 JSON,將日期時間轉換為 ISO 8601 格式。
- 否則,返回原始的
datetime
物件。
- 定義如何序列化
-
DateTimeUTC
定義:
這是一個使用Annotated
的複合類型,結合了多個 Pydantic 驗證器和序列化器:BeforeValidator(parse_datetime)
: 在驗證前,嘗試將輸入解析為datetime
物件。AfterValidator(make_utc)
: 在驗證後,確保日期時間是 UTC 格式。PlainSerializer(dt_serializer, when_used="unless-none")
: 定義如何序列化這個字段,除非值為 None。
自定義類型 DateTimeUTC
的目的:
- 自動解析各種格式的日期時間輸入。
- 確保所有日期時間都被轉換和存儲為 UTC 時間。
- 在輸出為 JSON 時,將日期時間格式化為標準的 ISO 8601 字符串。
對於跨時區的應用程序或需要精確時間記錄的系統特別有用。
好的,程式可以來收尾了。
建立 RequestInfo
和 ResponseBaseModel
,繼承自我們的預設模型 CustomBaseModel
。
-
定義一個通用的請求信息結構(
RequestInfo
),用來追踪每個請求的唯一 ID、執行時間和耗時。 -
創建一個基礎回應模型(
ResponseBaseModel
),包含請求信息。 -
定義一個特定的回應模型(
Users
),除了基礎的請求信息外,還包含一個用戶列表。 -
展示如何創建這些模型的實例,並將其序列化為 JSON 格式。
from uuid import uuid4
class RequestInfo(CustomBaseModel):
query_id: uuid4 = Field(default_factory=uuid4)
execution_dt: DateTimeUTC = Field(default_factory=lambda: datetime.now(pytz.utc))
elapsed_time_secs: float
class ResponseBaseModel(CustomBaseModel):
request_info: RequestInfo
class Users(ResponseBaseModel):
users: list[str] = []
users = Users(request_info=RequestInfo(elapsed_time_secs=3.14), users=["Athos", "Porthos", "Aramis"])
print(users.model_dump_json(by_alias=True, indent=2))
{
"requestInfo": {
"queryId": "9a8ade44-bf85-4beb-b06d-a5a9721394f8",
"executionDt": "2024-07-26T11:41:52Z",
"elapsedTimeSecs": 3.14
},
"users": [
"Athos",
"Porthos",
"Aramis"
]
}
這裡老師有稍微提到,如果以 composition 而不是繼承的方式做的話...
使用繼承(inheritance)和組合(composition)在這種情況下的不同:
-
繼承(Inheritance)方式:
在這個例子中,我們使用了繼承:
class ResponseBaseModel(CustomBaseModel): request_info: RequestInfo class Users(ResponseBaseModel): users: list[str] = []
優點:
- 所有繼承自
ResponseBaseModel
的類別自動包含request_info
。 - 減少重複代碼,保持一致性。
- 可以在
ResponseBaseModel
中添加通用方法,所有子類都能使用。
缺點:
- 可能導致深層繼承結構,使代碼難以理解和維護。
- 不夠靈活,如果某個特定響應不需要
request_info
,也難以避免。
- 所有繼承自
-
組合(Composition)方式:
如果使用組合,代碼可能如下:
class Users(CustomBaseModel): request_info: RequestInfo users: list[str] = []
優點:
- 更靈活,可以選擇性地在需要的響應模型中添加
request_info
。 - 避免了繼承可能帶來的複雜性。
缺點:
- 需要在每個需要
request_info
的響應模型中重複定義這個字段。 - 可能導致代碼重複,增加維護難度。
- 更靈活,可以選擇性地在需要的響應模型中添加
在這個特定的場景中,使用繼承更有優勢,因為:
- 通用性:我們希望每個 API 響應都包含這些基本信息。
- 一致性:確保所有響應模型都有相同的基本結構。
- 維護性:如果需要修改或添加通用字段,只需要在一個地方(
ResponseBaseModel
)進行更改。
然而,組合方法在某些情況下可能更合適,例如:
- 當不是所有響應都需要這些信息時。
- 當你需要更大的靈活性來組合不同類型的信息時。
▌Project (Section 11)
和之前一樣,本節內容講什麼(Model Composition 模型組合 & Model Inheritance 模型繼承),專案就實作什麼。
-
Model Inheritance 模型繼承:自訂基本模型(CamelBaseModel),然後讓 Automobile 繼承 CamelBaseModel,而不是 BaseModel。
-
Model Composition 模型組合:新增 RegistrationCountry,同樣繼承 CamelBaseModel,並且把原來在 Automobile 的相關資料(registration_country, registration_country_code),移到這裡。最後讓這個 Model,成為 Automobile 的其中一個欄位。
-
registration_country
移入新模型 RegistrationCountry 中,並命名為name
。 -
registration_country_code
移入新模型 RegistrationCountry 中,並命名為code3
。
-
Model Inheritance
CamelBaseModel
其實就只是把原先 Automobile
裡的 model_config
移過來而已。
class CamelBaseModel(BaseModel):
model_config = ConfigDict(
extra="forbid",
str_strip_whitespace=True,
validate_default=True,
validate_assignment=True,
alias_generator=to_camel,
)
Model Composition
實作那個要放進 Automobile 的 RegistrationCountry。
class RegistrationCountry(CamelBaseModel):
# name: Country | None = Field(default=None)
name: Country | None = None
@computed_field
@cached_property
def code3(self) -> str:
return country_code_lookup[self.name]
- class Automobile(BaseModel):
+ class Automobile(CamelBaseModel):
- model_config = ConfigDict(
- extra="forbid",
- str_strip_whitespace=True,
- validate_default=True,
- validate_assignment=True,
- alias_generator=to_camel,
- )
id_: UUID4 | None = Field(alias="id", default_factory=uuid4)
manufacturer: BoundedString
series_name: BoundedString
type_: AutomobileType = Field(alias="type")
is_electric: bool = Field(default=False, repr=False)
manufactured_date: CustomDate = Field(
validation_alias="completionDate",
ge=date(1980, 1, 1),
repr=False
)
base_msrp_usd: float = Field(
validation_alias="msrpUSD",
serialization_alias="baseMSRPUSD",
repr=False,
)
top_features: BoundedList[BoundedString] | None = Field(default=None, repr=False)
vin: BoundedString = Field(repr=False)
number_of_doors: int = Field(
default=4,
validation_alias="doors",
ge=2,
le=4,
multiple_of=2,
repr=False,
)
- registration_country: Country | None = Field(default=None, repr=False)
+ registration_country: RegistrationCountry | None = Field(default=None, repr=False)
# 其實也主要是移到 RegistrationCountry
- @computed_field(repr=False)
- @cached_property
- def registration_country_code(self) -> str:
- return country_code_lookup[self.registration_country]
registration_date: CustomDate | None = Field(default=None, repr=False)
license_plate: BoundedString | None = Field(default=None, repr=False)
@field_validator("registration_date")
@classmethod
def validate_registration_date(cls, value:date, values: ValidationInfo):
data = values.data
if "manufactured_date" in data and data["manufactured_date"] > value:
raise ValueError("Automobile cannot be registered prior to manufacture date.")
return value
因為 RegistrationCountry 的關係,原始資料一樣,但序列化格式稍有修改。
from uuid import UUID
data = {
"id": "c4e60f4a-3c7f-4da5-9b3f-07aee50b23e7",
"manufacturer": "BMW",
"seriesName": "M4 Competition xDrive",
"type": "Convertible",
"isElectric": False,
"completionDate": "2023-01-01",
"msrpUSD": 93_300,
"topFeatures": ["6 cylinders", "all-wheel drive", "convertible"],
"vin": "1234567890",
"doors": 2,
"registrationCountry": {"name": "us"},
"registrationDate": "2023-06-01",
"licensePlate": "AAA-BBB"
}
expected_serialized_by_alias = {
'id': UUID('c4e60f4a-3c7f-4da5-9b3f-07aee50b23e7'),
'manufacturer': 'BMW',
'seriesName': 'M4 Competition xDrive',
'type': AutomobileType.convertible,
'isElectric': False,
'manufacturedDate': date(2023, 1, 1),
'baseMSRPUSD': 93300.0,
'topFeatures': ['6 cylinders', 'all-wheel drive', 'convertible'],
'vin': '1234567890',
'numberOfDoors': 2,
'registrationCountry': {
'name': 'United States of America',
'code3': 'USA'
},
'registrationDate': date(2023, 6, 1),
'licensePlate': 'AAA-BBB'
}
car = Automobile.model_validate(data)
car
car.registration_country
Automobile(id_=UUID('c4e60f4a-3c7f-4da5-9b3f-07aee50b23e7'), manufacturer='BMW', series_name='M4 Competition xDrive', type_=<AutomobileType.convertible: 'Convertible'>)
RegistrationCountry(name='United States of America', code3='USA')
反序列化:
car.model_dump(by_alias=True)
{'id': UUID('c4e60f4a-3c7f-4da5-9b3f-07aee50b23e7'),
'manufacturer': 'BMW',
'seriesName': 'M4 Competition xDrive',
'type': <AutomobileType.convertible: 'Convertible'>,
'isElectric': False,
'manufacturedDate': datetime.date(2023, 1, 1),
'baseMSRPUSD': 93300.0,
'topFeatures': ['6 cylinders', 'all-wheel drive', 'convertible'],
'vin': '1234567890',
'numberOfDoors': 2,
'registrationCountry': {'name': 'United States of America', 'code3': 'USA'},
'registrationDate': datetime.date(2023, 6, 1),
'licensePlate': 'AAA-BBB'}
assert car.model_dump(by_alias=True) == expected_serialized_by_alias
▌專案回顧
拆幾部分重新觀看,可讀性較佳。
Imports
from datetime import date
from enum import Enum
from functools import cached_property
from typing import Annotated, TypeVar
from uuid import uuid4
from pydantic import (
AfterValidator,
BaseModel,
ConfigDict,
computed_field,
Field,
field_serializer,
field_validator,
PlainSerializer,
UUID4,
ValidationInfo,
)
from pydantic.alias_generators import to_camel
Data
countries = {
"australia": ("Australia", "AUS"),
"canada": ("Canada", "CAN"),
"china": ("China", "CHN"),
"france": ("France", "FRA"),
"germany": ("Germany", "DEU"),
"india": ("India", "IND"),
"mexico": ("Mexico", "MEX"),
"norway": ("Norway", "NOR"),
"pakistan": ("Pakistan", "PAK"),
"san marino": ("San Marino", "SMR"),
"sanmarino": ("San Marino", "SMR"),
"spain": ("Spain", "ESP"),
"sweden": ("Sweden", "SWE"),
"united kingdom": ("United Kingdom", "GBR"),
"uk": ("United Kingdom", "GBR"),
"great britain": ("United Kingdom", "GBR"),
"britain": ("United Kingdom", "GBR"),
"us": ("United States of America", "USA"),
"united states": ("United States of America", "USA"),
"usa": ("United States of America", "USA"),
}
valid_country_names = sorted(countries.keys())
country_code_lookup = {
name: code
for name, code in countries.values()
}
Enums and Helper Functions
class AutomobileType(Enum):
sedan = "Sedan"
coupe = "Coupe"
convertible = "Convertible"
suv = "SUV"
truck = "Truck"
def lookup_country(name: str) -> tuple[str, str]:
name = name.strip().casefold()
try:
return countries[name]
except KeyError:
raise ValueError(
"Unknown country name. "
f"Country name must be one of: {','.join(valid_country_names)}"
)
def serialize_date(value: date) -> str:
return value.strftime("%Y/%m/%d")
Annotated Types
T = TypeVar('T')
BoundedString = Annotated[str, Field(min_length=2, max_length=50)]
BoundedList = Annotated[list[T], Field(min_length=1, max_length=5)]
Country = Annotated[str, AfterValidator(lambda name: lookup_country(name)[0])]
CustomDate = Annotated[
date,
PlainSerializer(serialize_date, when_used="json-unless-none")
]
Custom Base Model
class CamelBaseModel(BaseModel):
model_config = ConfigDict(
extra="forbid",
str_strip_whitespace=True,
validate_default=True,
validate_assignment=True,
alias_generator=to_camel,
)
Models
class RegistrationCountry(CamelBaseModel):
name: Country | None = Field(default=None)
@computed_field
@cached_property
def code3(self) -> str:
return country_code_lookup[self.name]
class Automobile(CamelBaseModel):
id_: UUID4 | None = Field(alias="id", default_factory=uuid4)
manufacturer: BoundedString
series_name: BoundedString
type_: AutomobileType = Field(alias="type")
is_electric: bool = Field(default=False, repr=False)
manufactured_date: CustomDate = Field(
validation_alias="completionDate",
ge=date(1980, 1, 1),
repr=False
)
base_msrp_usd: float = Field(
validation_alias="msrpUSD",
serialization_alias="baseMSRPUSD",
repr=False,
)
top_features: BoundedList[BoundedString] | None = Field(default=None, repr=False)
vin: BoundedString = Field(repr=False)
number_of_doors: int = Field(
default=4,
validation_alias="doors",
ge=2,
le=4,
multiple_of=2,
repr=False,
)
registration_country: RegistrationCountry | None = Field(default=None, repr=False)
registration_date: CustomDate | None = Field(default=None, repr=False)
license_plate: BoundedString | None = Field(default=None, repr=False)
@field_validator("registration_date")
@classmethod
def validate_registration_date(cls, value:date, values: ValidationInfo):
data = values.data
if "manufactured_date" in data and data["manufactured_date"] > value:
raise ValueError("Automobile cannot be registered prior to manufacture date.")
return value