Posts
Most useful functional pattern in Python
Problem
Python without external libraries lacks disjoined unions
You may model this
enum Currency {
Euro,
Dollar,
Peanuts,
}
Like so
from enum import Enum
class Currency(Enum):
EURO = "Euro"
DOLLAR = "Dollar"
PEANUTS = "Peanuts"
But what about
enum Action {
Start,
Move { x: i32, y: i32 },
}
Functional solution
There comes pydantic and definition of Action
import pydantic
from typing import Annotated
from typing import Literal
class Start(pydantic.BaseModel):
choice: Literal["start"] = "start"
class Move(pydantic.BaseModel):
choice: Literal["move"] = "move"
x: int
y: int
Action = Annotated[Start | Move, pydantic.Field(discriminator="choice")]
Variants defined as variables
action: Action = Start()
action: Action = Move(x=1, y=2)
Conditioning based on variable class
if isinstance(action, Start):
print(f"started")
elif isinstance(action, Move):
print(f"moved {action.x=!r}")
print(f"moved {action.y=!r}")
Or conditioning based on choice attribute
useful due to functional limitations, for example in Jinja/Django templates or when bringing class references is burdensome
if action.choice == "start":
print(f"started")
elif action.choice == "move":
print(f"moved {action.x=!r}")
print(f"moved {action.y=!r}")
Parsing json using Action results in dispatch to the proper variants
start_json = "{\"choice\": \"start\"}"
assert isinstance(pydantic.RootModel[Action].model_validate_json(start_json).root, Start)
move_json = "{\"choice\": \"move\", \"x\": 1, \"y\": 2}"
assert isinstance(pydantic.RootModel[Action].model_validate_json(move_json).root, Move)
Bonus, with generics:
from typing import Generic, TypeVar, Union, Literal
T = TypeVar("T")
E = TypeVar("E")
# I recommend defining a base model with arbitrary_types_allowed
# and using this for everything because default is too restrictive
class BaseModel(pydantic.BaseModel):
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
# For models that need serialization/deserialization opt in for SerializableBaseModel
class SerializableBaseModel(pydantic.BaseModel):
model_config = pydantic.ConfigDict(arbitrary_types_allowed=False)
class Ok(BaseModel, Generic[T]):
choice: Literal["Ok"] = "Ok"
value: T
class Err(BaseModel, Generic[E]):
choice: Literal["Err"] = "Err"
value: E
class Result(BaseModel, Generic[T, E]):
value: Annotated[Ok[T] | Err[E], pydantic.Field(discriminator="choice")]
MyResult = Result[int, Exception]
ok_value: MyResult = Ok(value=1)
err_value: MyResult = Err(value=ZeroDivisionError())
Conclusion
This is a lifesafer for functional python programming