Section 11: Complex Models 複合模型

▌簡介

  • 到目前為止,老師所有範例的模型欄位,都是 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 則是:

  1. Person 類中,我們將 notable_students 欄位定義為 Field,並使用 default_factory 參數設置為一個返回空列表的 lambda 函數。

  2. 我們重寫了 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 的類型註解系統,這確實是一個更好的實現。讓我解釋一下這個方法的優點:

  1. 使用 AfterValidator
    這確保了每次 notable_students 被賦值時都會自動排序,不僅僅是在模型創建時。

  2. SortedStringList 類型註解:
    這創建了一個自定義類型,可以在多個地方重用,使代碼更加模塊化和可維護。

  3. 使用 str.casefold 而不是 str.lower
    casefold 提供了更強大的大小寫轉換,特別是對於某些特殊的 Unicode 字符。

  4. 簡化了 Person 類:
    不需要重寫 model_validate_json 方法,使類定義更加簡潔。

這個實現方式有以下優勢:

  • 更加聲明式:通過類型註解清晰地表達了字段的行為。

  • 更加靈活:可以輕易地在其他模型中重用 SortedStringList

  • 自動化:無論何時賦值給 notable_students,都會自動排序。

  • 類型安全:提供了更好的類型提示,有利於 IDE 的自動完成和類型檢查。

使用這種方法,每次 notable_students 被賦值或修改時,它都會自動按照不區分大小寫的字母順序排序。這包括模型初始化、JSON 驗證、甚至直接賦值操作。

這是一個非常好的實現方式,它充分利用了 Pydantic 的高級特性,提供了一個優雅且可重用的解決方案。非常好的改進!

Python composition

高中資訊教師黃建庭的教學網站 - Python類別與例外

▌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 時間。

  1. make_utc 函數:

    • 將給定的 datetime 物件轉換為 UTC 時間。
    • 如果輸入的日期時間沒有時區信息,假定它為 UTC 時間。
    • 如果有時區信息,則將其轉換為 UTC。
  2. parse_datetime 函數:

    • 嘗試將輸入值解析為 datetime 物件。
    • 如果輸入是字符串,使用 dateutil.parser.parse 進行解析。
    • 如果解析失敗,拋出 ValueError
    • 如果輸入不是字符串,則直接返回。
  3. dt_serializer 函數:

    • 定義如何序列化 datetime 物件。
    • 如果序列化模式是 JSON,將日期時間轉換為 ISO 8601 格式。
    • 否則,返回原始的 datetime 物件。
  4. DateTimeUTC 定義:
    這是一個使用 Annotated 的複合類型,結合了多個 Pydantic 驗證器和序列化器:

    • BeforeValidator(parse_datetime): 在驗證前,嘗試將輸入解析為 datetime 物件。
    • AfterValidator(make_utc): 在驗證後,確保日期時間是 UTC 格式。
    • PlainSerializer(dt_serializer, when_used="unless-none"): 定義如何序列化這個字段,除非值為 None。

自定義類型 DateTimeUTC 的目的:

  • 自動解析各種格式的日期時間輸入。
  • 確保所有日期時間都被轉換和存儲為 UTC 時間。
  • 在輸出為 JSON 時,將日期時間格式化為標準的 ISO 8601 字符串。

對於跨時區的應用程序或需要精確時間記錄的系統特別有用。

好的,程式可以來收尾了。

建立 RequestInfoResponseBaseModel ,繼承自我們的預設模型 CustomBaseModel

  1. 定義一個通用的請求信息結構(RequestInfo),用來追踪每個請求的唯一 ID、執行時間和耗時。

  2. 創建一個基礎回應模型(ResponseBaseModel),包含請求信息。

  3. 定義一個特定的回應模型(Users),除了基礎的請求信息外,還包含一個用戶列表。

  4. 展示如何創建這些模型的實例,並將其序列化為 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)在這種情況下的不同:

  1. 繼承(Inheritance)方式:

    在這個例子中,我們使用了繼承:

    class ResponseBaseModel(CustomBaseModel):
        request_info: RequestInfo    
    
    class Users(ResponseBaseModel):
        users: list[str] = []
    

    優點:

    • 所有繼承自 ResponseBaseModel 的類別自動包含 request_info
    • 減少重複代碼,保持一致性。
    • 可以在 ResponseBaseModel 中添加通用方法,所有子類都能使用。

    缺點:

    • 可能導致深層繼承結構,使代碼難以理解和維護。
    • 不夠靈活,如果某個特定響應不需要 request_info,也難以避免。
  2. 組合(Composition)方式:

    如果使用組合,代碼可能如下:

    class Users(CustomBaseModel):
        request_info: RequestInfo
        users: list[str] = []
    

    優點:

    • 更靈活,可以選擇性地在需要的響應模型中添加 request_info
    • 避免了繼承可能帶來的複雜性。

    缺點:

    • 需要在每個需要 request_info 的響應模型中重複定義這個字段。
    • 可能導致代碼重複,增加維護難度。

在這個特定的場景中,使用繼承更有優勢,因為:

  1. 通用性:我們希望每個 API 響應都包含這些基本信息。
  2. 一致性:確保所有響應模型都有相同的基本結構。
  3. 維護性:如果需要修改或添加通用字段,只需要在一個地方(ResponseBaseModel)進行更改。

然而,組合方法在某些情況下可能更合適,例如:

  • 當不是所有響應都需要這些信息時。
  • 當你需要更大的靈活性來組合不同類型的信息時。

▌Project (Section 11)

和之前一樣,本節內容講什麼(Model Composition 模型組合 & Model Inheritance 模型繼承),專案就實作什麼。

  1. Model Inheritance 模型繼承:自訂基本模型(CamelBaseModel),然後讓 Automobile 繼承 CamelBaseModel,而不是 BaseModel。

  2. 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

▌本章 Jupyter notebook GitHub

1個讚