Duck API¶
The Duck API provides an ergonomic interface for duck typing, especially when working with Pydantic models. It makes structural typing as natural as using isinstance().
Overview¶
The Duck API centers around the Duck factory function that creates duck types from various sources:
from pydantic import BaseModel
from duckdantic import Duck
class User(BaseModel):
id: int
name: str
email: str
# Create a duck type
UserDuck = Duck(User)
# Now use it naturally
data = {"id": 1, "name": "Alice", "email": "alice@example.com"}
assert isinstance(data, UserDuck) # ✅ Structural check!
Creating Duck Types¶
From Pydantic Models¶
The most common use case:
from pydantic import BaseModel
from duckdantic import Duck
class Product(BaseModel):
id: int
name: str
price: float
in_stock: bool = True
# Create duck type
ProductDuck = Duck(Product)
# Or use generic syntax
ProductDuck = Duck[Product]
From Fields¶
Create duck types from individual fields:
from pydantic import Field
from duckdantic import Duck
EmailField = Field(..., pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')
EmailDuck = Duck(EmailField)
# Check if something has a valid email field
data = {"value": "user@example.com"}
assert isinstance(data, EmailDuck)
From Traits¶
Use existing trait specifications:
from duckdantic import Duck, TraitSpec, FieldSpec
UserTrait = TraitSpec(
name="User",
fields=(
FieldSpec("id", int),
FieldSpec("name", str),
)
)
UserDuck = Duck(UserTrait)
Using Duck Types¶
isinstance Checks¶
The most natural way to use duck types:
# Check dictionaries
user_dict = {"id": 1, "name": "Bob", "email": "bob@example.com"}
assert isinstance(user_dict, UserDuck)
# Check objects
class Customer:
def __init__(self, id: int, name: str, email: str):
self.id = id
self.name = name
self.email = email
customer = Customer(2, "Charlie", "charlie@example.com")
assert isinstance(customer, UserDuck)
# Check Pydantic models
from pydantic import BaseModel
class Employee(BaseModel):
id: int
name: str
email: str
department: str
employee = Employee(id=3, name="David", email="david@example.com", department="IT")
assert isinstance(employee, UserDuck)
Validation Methods¶
Duck types provide several validation methods:
# Basic validation
if UserDuck.validate(data):
print("Valid user data!")
# Assertion with custom message
UserDuck.assert_valid(data, "Invalid user data provided")
# Get the underlying trait
trait = UserDuck.trait
print(f"Required fields: {[f.name for f in trait.fields if f.required]}")
Type Conversion¶
Convert compatible objects to the target model:
# From dictionary
user_dict = {"id": 1, "name": "Alice", "email": "alice@example.com"}
user = UserDuck.convert(user_dict)
print(user) # User(id=1, name='Alice', email='alice@example.com')
# From another object
class Person:
def __init__(self, id: int, name: str, email: str):
self.id = id
self.name = name
self.email = email
person = Person(2, "Bob", "bob@example.com")
user = UserDuck.convert(person)
print(user) # User(id=2, name='Bob', email='bob@example.com')
# With additional fields
partial = {"id": 3, "name": "Charlie"}
user = UserDuck.convert(partial, email="charlie@example.com")
BaseModel Extensions¶
Duckdantic extends Pydantic's BaseModel with duck typing methods:
Class Methods¶
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
email: str
# Check if object satisfies model structure
data = {"id": 1, "name": "Alice", "email": "alice@example.com"}
assert User.__duck_validates__(data)
# Convert duck-typed object to model
user = User.__duck_convert__(data)
Convenience Functions¶
from duckdantic import is_duck_of, as_duck
# Quick check
if is_duck_of(data, User):
print("It's user-like!")
# Quick conversion
user = as_duck(User, data)
DuckModel Base Class¶
For enhanced duck typing support, inherit from DuckModel:
from duckdantic import DuckModel
class User(DuckModel):
id: int
name: str
email: str
# Get the duck type
UserDuck = User.duck_type
# Check compatibility
data = {"id": 1, "name": "Alice", "email": "alice@example.com"}
assert User.is_duck(data)
# Convert from duck
user = User.from_duck(data)
DuckRootModel¶
For root models (lists, dicts, etc.):
from typing import List
from duckdantic import DuckRootModel
class UserList(DuckRootModel[List[User]]):
pass
# Validate list of user-like objects
data = [
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"},
]
assert UserList.__duck_validates__(data)
users = UserList.from_duck(data)
Custom Policies¶
Control type checking behavior:
from duckdantic import Duck, TypeCompatPolicy
# Strict policy
strict_policy = TypeCompatPolicy(
allow_numeric_widening=False,
allow_optional_widening=False,
)
StrictUserDuck = Duck(User, policy=strict_policy)
# Now int won't satisfy float fields
data = {"id": 1, "name": "Alice", "balance": 100} # balance is int
assert not isinstance(data, StrictUserDuck) # If balance should be float
Generic Syntax¶
Duck supports generic syntax for type hints:
from typing import TypeVar
from duckdantic import Duck
T = TypeVar('T')
def validate_data(data: dict, model: type[T]) -> T:
"""Validate and convert data to model using duck typing."""
ModelDuck = Duck[model]
if not isinstance(data, ModelDuck):
raise ValueError(f"Data doesn't match {model.__name__} structure")
return ModelDuck.convert(data)
# Usage
user = validate_data({"id": 1, "name": "Alice", "email": "a@b.com"}, User)
Common Patterns¶
API Response Handling¶
from typing import Generic, TypeVar
from pydantic import BaseModel
from duckdantic import Duck, DuckModel
T = TypeVar('T')
class APIResponse(BaseModel, Generic[T]):
success: bool
data: T
error: str | None = None
class User(DuckModel):
id: int
name: str
# Create duck type for user response
UserResponseDuck = Duck[APIResponse[User]]
# Validate response
response = {
"success": True,
"data": {"id": 1, "name": "Alice"},
"error": None
}
if isinstance(response, UserResponseDuck):
user_response = UserResponseDuck.convert(response)
user = user_response.data
Form Validation¶
class RegistrationForm(DuckModel):
username: str
email: str
password: str
confirm_password: str
def process_registration(form_data: dict):
# Quick validation
if not RegistrationForm.is_duck(form_data):
raise ValueError("Invalid registration data")
# Convert and validate business rules
form = RegistrationForm.from_duck(form_data)
if form.password != form.confirm_password:
raise ValueError("Passwords don't match")
# Process registration...
Type Bridging¶
# Convert between similar types from different libraries
class ExternalUser:
def __init__(self, user_id: int, username: str, user_email: str):
self.user_id = user_id
self.username = username
self.user_email = user_email
class InternalUser(DuckModel):
id: int
name: str
email: str
# Create adapter
def adapt_user(external: ExternalUser) -> InternalUser:
# Map fields appropriately
data = {
"id": external.user_id,
"name": external.username,
"email": external.user_email
}
# Convert using duck typing
return InternalUser.from_duck(data)
Performance Tips¶
- Cache Duck Types: Create once, reuse many times
# Good - create once
USER_DUCK = Duck(User)
def validate_users(users: list[dict]):
return all(isinstance(u, USER_DUCK) for u in users)
- Use Direct Methods: For hot paths, use model methods directly
- Batch Validation: Validate multiple objects together
Next Steps¶
- Learn about Traits for more complex requirements
- Explore Type Policies for customization
- Check out Advanced Topics (coming soon) for optimization