Python object sugar
I read https://glyph.twistedmatrix.com/2016/08/attrs.html the other day while failing to get things done, and it really piqued my interest. I do a fair amount of “write a small plumbing utility” stuff in Python, and I basically never write new classes. I also occasionally regret that when things need real maintenance and grow over a couple of screenfuls.
So I took attrs, dataclasses, and pydantic, the three main competitors in this space for a test drive. Lets do attrs first, since that’s where I got started-
import attr
@attr.s
class InventoryItem:
name: str = attr.ib()
unit_price: float = attr.ib(validator=attr.validators.instance_of(float), converter=float)
quantity: int = attr.ib(default=0)
def total_cost(self) -> float:
return self.unit_price * self.quantity
kitty_clock = InventoryItem("kitty clock", 21.99, 5)
print(kitty_clock.name)
print(kitty_clock.unit_price)
print(kitty_clock.quantity)
print(kitty_clock.total_cost())
print(kitty_clock == kitty_clock)
print("type coercion comparison")
print(InventoryItem("book", 9.99) == InventoryItem(unit_price="9.99", name="book"))
print(InventoryItem("book", 9.99))
print(InventoryItem("beast", "sloppy")) # Throws exception
That’s not terrible, and the features seem pretty reasonable. What I really don’t understand is the type validation. It uh… doesn’t work based on the native Python3 type labels, you have to manually do validator=attr.validators.instance_of(float) which just seems like nonsense I don’t want to have to remember.
I guess this is just par for the course- the official theory on Python types is you should run mypy on your laptop and not in production- but this seems sort of crazymaking to me. Why do we need to invent wild new frontiers in “works on my machine”, I thought we’d finally all agreed that that was bad.
Dataclasses is attrs, but run through the PEP committee process & vendored into the Standard Library. I’m a big fan of living off of the land in the standard library if at all possible, so I definitely wanted to take this for a test drive. To be perfectly honest, it’s much cleaner thanattrs:
from dataclasses import dataclass
@dataclass
class InventoryItem:
name: str
unit_price: float
quantity: int = 0
def total_cost(self) -> float:
return self.unit_price * self.quantity
and usage is character-for-character identical to attrs. Except that that unit_price: float is a lie and worthless, because again, Python types are comments. So
print(InventoryItem("beast", "sloppy")) # Throws exception
does not throw an exception, and InventoryItem("kitty clock", "21.99", 5).total_cost() returns 21.9921.9921.9921.9921.99 which is frustrating. This is fixable-
def __post_init__(self):
self.unit_price = float(self.unit_price)
but is also supremely annoying. What’s the point of type annotations if they’re just going to be ignored?
Pydantic, the third library, fixes this.
from pydantic.dataclasses import dataclass
@dataclass
class InventoryItem:
name: str
unit_price: float
quantity: int = 0
def total_cost(self) -> float:
return self.unit_price * self.quantity
Creates an object that behaves identically to the dataclass and attrs examples, and does automatic type validation, and will automatically convert "9.99" (the string) to 9.99 (the float). It’s perfect and wonderful.
This is not the native Pydantic model though, they prefer a base object over the magic decorator-
from pydantic import BaseModel
class InventoryItem(BaseModel):
name: str
unit_price: float
quantity: int = 0
def total_cost(self) -> float:
return self.unit_price * self.quantity
This is a more natural integration into the Python type system, which is nice. Definitely less magical and cutesy than @attr.s.
Usage, unfortunately, is a little different-
kitty_clock = InventoryItem(name="kitty clock", unit_price=21.99, quantity=5)
No positional arguments allowed! I understand and empathize with this view, but I’m also in the business of “lots of little scripts quickly” business, so I kind of just like positional arguments.
The result object works the same in all other respects, and
print(kitty_clock.name)
print(kitty_clock.unit_price)
print(kitty_clock.quantity)
print(kitty_clock.total_cost())
print(kitty_clock == kitty_clock)
print("type coercion comparison")
print(InventoryItem(name="book", unit_price=9.99) == InventoryItem(unit_price="9.99", name="book"))
print(InventoryItem(name="book", unit_price=9.99))
print(InventoryItem(name="beast", unit_price="sloppy")) # Throws exception
gives the same results as the other two implementations.
So! Interesting stuff. I think I’m going to try and give Pydantic-implemented Dataclasses some run in the future. It’s a nice combination of sloppy-but-typed that really appeals to me.