Extending
Write the assertions your domain needs — is_shippable, is_settled, has_valid_signature — as methods on a subject of your own.
A subject of your own
from typing import Self
from lovely_assertions import Expect, custom_assertion, expect, AssertionFailure
class Money:
__slots__ = ("cents",)
def __init__(self, cents: int) -> None:
self.cents = cents
class MoneyExpect(Expect[Money]):
__slots__ = ()
@custom_assertion
def is_positive(self, *, because: str = "") -> Self:
if self._subject.cents > 0:
return self
return self._fail(f"to be positive, but was {self._subject.cents} cents", because)
refund = Money(-50)
try:
expect(refund, as_=MoneyExpect).is_positive()
except AssertionFailure as failure:
print(failure)
try:
expect(refund, as_=MoneyExpect).is_positive(because="refunds are stored positive")
except AssertionFailure as failure:
print(failure)Expected refund to be positive, but was -50 cents. Expected refund to be positive, but was -50 cents because refunds are stored positive.
Note what you did not have to write: the word Expected, the subject's name, the
full stop, and the joining of because= onto the end. Those are added in one
place, which is what makes everything below work for your assertion without you
wiring any of it up.
The five rules
1. Subclass Expect[T] and give it __slots__ = (). A subject holds one
attribute and is allocated once per assertion; a __dict__ on each one is
measurable across a real suite.
2. Decorate every assertion with @custom_assertion. Without it, name
recovery reads your own method's frame instead of the caller's, finds no
expect(...) in it, and gives up on the name:
from typing import Self
from lovely_assertions import Expect, expect, AssertionFailure
class Money:
__slots__ = ("cents",)
def __init__(self, cents: int) -> None:
self.cents = cents
class Unmarked(Expect[Money]):
__slots__ = ()
def is_positive(self, *, because: str = "") -> Self:
if self._subject.cents > 0:
return self
return self._fail("to be positive", because)
refund = Money(-50)
try:
expect(refund, as_=Unmarked).is_positive()
except AssertionFailure as failure:
print(failure)Expected the value to be positive.
the value instead of refund. The decorator is signature-transparent — it
keeps your exact type, Self return and keyword-only because included.
3. Test, then return self — or self._fail(...). In that order. A passing
assertion must be a comparison and a return, so build the message inside the
failure branch. Passing an f-string as an argument to a helper builds it on the
happy path too, which is the mistake this rule exists to prevent:
# Wrong: the message is built on every call, passing or failing.
return self._check(self._subject.cents > 0, f"to be positive, but was {...}")4. Write the middle of a sentence. Lower case, no leading Expected, no
trailing full stop, and say both halves — what you wanted and what was there.
"to be positive" is half a message; "to be positive, but was -50 cents" is a
whole one. See the grammar.
5. Accept because: str = "" as keyword-only, and pass it to _fail. Users
expect it on every assertion, including yours.
Two ways to reach your subject
as_= — explicit and statically typed
expect(refund, as_=MoneyExpect).is_positive()A type checker knows exactly what comes back, so completion and narrowing work exactly as they do for the built-in subjects. This is the route to prefer.
register() — automatic, and not statically narrowable
register() is invisible to a checker, which is exactly what this section documents
from typing import Self
from lovely_assertions import Expect, custom_assertion, expect, register, AssertionFailure
class Money:
__slots__ = ("cents",)
def __init__(self, cents: int) -> None:
self.cents = cents
class MoneyExpect(Expect[Money]):
__slots__ = ()
@custom_assertion
def is_positive(self, *, because: str = "") -> Self:
if self._subject.cents > 0:
return self
return self._fail(f"to be positive, but was {self._subject.cents} cents", because)
register(Money, MoneyExpect)
refund = Money(-50)
try:
expect(refund).is_positive()
except AssertionFailure as failure:
print(failure)Expected refund to be positive, but was -50 cents.
A plain expect(refund) now builds a MoneyExpect at runtime. A type
checker still reads the declared overload set and says Expect[Money], so
is_positive is a checker error even though it works. That is a limitation of
the language rather than an oversight — no checker can see a runtime
registration — and it is why as_= exists.
Matching is by exact type. A subclass of Money is a different type, so it falls
through to the plain Expect and is_positive on it is an AttributeError at
runtime: register each type you mean to reach, or use as_=.
Use register() when the values arrive from somewhere the annotations do not
reach, and as_= when you want the checking.
What register() refuses
A type cannot be registered twice, and a type that already has a subject cannot
be registered over. Both raise ValueError, and only at runtime — a checker
reads the two arguments against register's signature and nothing more, so
registering your own Expect[str] over str type-checks and then raises.
the checker objects that MoneyExpect takes a Money and not a str, which would fire for any type; the clash with the built-in subject is caught at runtime alone
from lovely_assertions import Expect, register
class Money:
__slots__ = ()
class MoneyExpect(Expect[Money]):
__slots__ = ()
register(Money, MoneyExpect)
try:
register(Money, MoneyExpect)
except ValueError as error:
print(error)
try:
register(str, MoneyExpect)
except ValueError as error:
print(error)Money is already registered str already has a subject; registering over it would put the runtime out of step with the static overloads of expect(), which go on answering StringExpect. Use expect(value, as_=YourExpect) where you need your own, or subclass StringExpect and register a type that has no subject yet
Registering over a built-in would make the runtime disagree with the overload chain — the one thing the dispatch design cannot survive.
Assertions that find something
Return a Found when your assertion locates a value the caller may want to keep
asserting on. Found[P, V] gives them .and_ (back to your subject), .which
(a subject over the found value) and .subject (the raw value):
from typing import Self
from lovely_assertions import Expect, Found, custom_assertion, expect
class Money:
__slots__ = ("cents",)
def __init__(self, cents: int) -> None:
self.cents = cents
class MoneyExpect(Expect[Money]):
__slots__ = ()
@custom_assertion
def has_cents(self, expected: int, /, *, because: str = "") -> "Found[Self, int]":
if self._subject.cents == expected:
return Found(self, self._subject.cents)
return self._fail_narrowing(
f"to have {expected} cents, but had {self._subject.cents}", because
)
@custom_assertion
def is_positive(self, *, because: str = "") -> Self:
if self._subject.cents > 0:
return self
return self._fail(f"to be positive, but was {self._subject.cents} cents", because)
expect(Money(50), as_=MoneyExpect).has_cents(50).and_.is_positive()
print("found, then continued")found, then continued
Quote the return annotation. Found's third parameter defaults to Expect[V],
a name the library imports for type checking only, so an unquoted
Found[Self, int] raises NameError when the annotation is evaluated — on
Python 3.13 while the class body still runs, on 3.14 whenever something reads
__annotations__.
Found[Self, str, StringExpect], with all three supplied, needs no quotes.
Use _fail_narrowing rather than _fail on an assertion that was supposed to
produce a narrowed subject: there is no narrowed subject to return, so a soft
scope gets a stand-in that absorbs the rest of the chain instead of a wrapper
whose static type is now a lie.
Found's third parameter lets you promise what .which hands back —
Found[Self, str, StringExpect]. It is a promise, not a proof: nothing ties
it to the value type, so declaring one thing and returning another type-checks
and then raises AttributeError. That is your responsibility, and
the reasoning is recorded.
Making your types read well in messages
Independently of assertions, teach every message how your types render:
from lovely_assertions import expect, register_formatter, ObjectFormatter, AssertionFailure
class Order:
def __init__(self, id: str, total: int) -> None:
self.id = id
self.total = total
register_formatter(ObjectFormatter(Order, "id", "total"))
placed = Order("ord-118", 4200)
try:
expect(placed).is_equal_to(Order("ord-119", 4200))
except AssertionFailure as failure:
print(failure)Expected placed to equal Order(id='ord-119', total=4200), but was Order(id='ord-118', total=4200).
Full detail in Controlling output.
What you inherit
Everything on Expect[T]: is_equal_to, is_none, satisfies,
described_as, .and_, .subject, and the rest. You can also subclass a
specific subject — class OrderListExpect(SequenceExpect[Order]) — and inherit
the whole sequence catalogue alongside your own assertions.
And everything cross-cutting works without you doing anything:
soft scopes collect your failures,
formatting() bounds your rendered values, and
because= attaches to your sentence. All of it because there is exactly one
place a failure is reported, and you called it.
Gotcha: @custom_assertion on a plain function
The decorator tells the name recovery to skip your frame and look at the caller's. That works for a method on a subject. On a standalone helper function that wraps an assertion, there is no subject-building call in the caller's statement to recover, so the name is lost entirely rather than improved. Write domain assertions as methods on a subject.