Controlling output
A failure message is bounded, and it renders your types with repr. When one is too short, formatting() raises the bounds for a block; when it renders your types badly, register_formatter() teaches it how they read.
Bounds: formatting()
Every rendering in the library is capped. A collection prints its first few items and counts the rest; one value prints about a terminal line; a diff prints a screenful; a difference report descends a couple of levels into nested structure.
from lovely_assertions import expect, AssertionFailure
audit_rows = list(range(60))
try:
expect(audit_rows).contains(999)
except AssertionFailure as failure:
print(failure)Expected audit_rows to contain 999, but was [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ... (50 more)].
Those defaults are chosen for the message you skim — the one that says at a glance which assertion went wrong. They are exactly wrong for the message you are debugging: a sixty-element list showing the first ten is least helpful precisely when the row that matters is the sixtieth, which is the moment you are looking.
So the bounds become a scope:
from lovely_assertions import expect, formatting, AssertionFailure
audit_rows = list(range(60))
try:
with formatting(max_items=20):
expect(audit_rows).contains(999)
except AssertionFailure as failure:
print(failure)Expected audit_rows to contain 999, but was [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, ... (40 more)].
| Option | Bounds |
|---|---|
max_items |
items shown from a collection |
max_chars |
characters shown for one value |
max_diff_lines |
lines shown in a unified diff |
max_depth |
levels a difference report descends into nested structure |
Exactly the assertions that were failing before still fail. The scope changes what a failure says, never whether it happens.
Nesting composes
None leaves a bound alone, and a scope resolves against whatever is in force
when it is entered:
from lovely_assertions import expect, formatting
with formatting(max_chars=500):
with formatting(max_items=100) as options:
print(options.max_items, options.max_chars)100 500
Asking for one bound is not a request to reset the others — so a context manager built in a fixture composes with whatever scope the test happens to be inside.
A bad limit is reported
from lovely_assertions import formatting
try:
formatting(max_items=0)
except ValueError as error:
print(error)max_items must be at least 1, not 0
max_items=0 would announce a failure and then decline to say anything about it.
That is a bug in the test, not a rendering preference.
Scoping is per context, not per process
The options in force live in a ContextVar, so one thread's or one asyncio
task's rendering never reaches another's messages. That is also why there is no
global setter: shared assertion state that each test mutates stops being safe the
moment the suite runs in parallel.
Nothing here costs a passing assertion anything — the ContextVar is read on the
failure path only.
Rendering: register_formatter()
repr is the right default and a poor one for domain objects. A message reading
<myapp.orders.Order object at 0x10f3a2d90> hands you a memory address in place
of the thing you are trying to understand.
ObjectFormatter — render an object through chosen attributes
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).
At least one attribute name is required: ObjectFormatter(Order) would render
Order(), which is less than repr already gives you. Subclasses are claimed
too, and the heading names the runtime type, so a subclass still says which one
it is.
IterableFormatter — render a domain collection as its contents
from lovely_assertions import expect, register_formatter, IterableFormatter, format_value
class OrderBook:
def __init__(self, *orders: int) -> None:
self.orders = list(orders)
def __iter__(self):
return iter(self.orders)
register_formatter(IterableFormatter(OrderBook, max_items=3))
print(format_value(OrderBook(1, 2, 3, 4, 5)))OrderBook[1, 2, 3, ... (more)]
That max_items is the formatter's own, fixed when you register it —
formatting(max_items=...) does not move it. Its default matches the one
formatting() starts from, so pass it only when this type has a reason to print
differently from everything else.
It claims the types you give it and their subclasses, not every iterable — a
formatter that claimed every iterable would sit in front of the whole registry,
and repr is the right rendering for a list of integers.
It says (more) rather than (2 more) there because OrderBook defines no
__len__, so there is no count to state — give your type a __len__ and the
number appears. Items are rendered through the registry too, so a nested type
with its own formatter reads through that one.
Writing your own
A formatter is any object with can_handle and format. That is the exported
ValueFormatter protocol, and it is structural — nothing has to inherit from
anything:
from lovely_assertions import expect, register_formatter, AssertionFailure
class Money:
def __init__(self, cents: int) -> None:
self.cents = cents
class MoneyFormatter:
def can_handle(self, value: object, /) -> bool:
return isinstance(value, Money)
def format(self, value: object, /) -> str:
assert isinstance(value, Money)
return f"EUR {value.cents / 100:.2f}"
register_formatter(MoneyFormatter())
price = Money(1250)
try:
expect(price).is_equal_to(Money(999))
except AssertionFailure as failure:
print(failure)Expected price to equal EUR 9.99, but was EUR 12.50.
Where to register
Once, at import — in a conftest module body — and never per test. Global
assertion state that each test edits stops being safe the moment the suite runs
in parallel, which is why registering the same formatter object twice raises
ValueError: the only way that happens is configuration running per test rather
than once.
Registration is first-come-wins. A later registration does not displace an earlier one, because then a message would depend on which module happened to be imported first.
To override a rendering for one block, pass the formatter to a soft scope. There is no way to scope a formatter without the rest of that scope: failures inside are collected and reported together on exit rather than raising at the first one, which is why the output below opens with a count.
from lovely_assertions import expect, soft_assertions, AssertionFailure
class Terse:
def can_handle(self, value: object, /) -> bool:
return isinstance(value, list)
def format(self, value: object, /) -> str:
assert isinstance(value, list)
return f"<{len(value)} rows>"
audit_rows = [1, 2, 3]
try:
with soft_assertions(formatters=(Terse(),)):
expect(audit_rows).is_equal_to([1, 2])
except AssertionFailure as failure:
print(failure)1 assertion failed:
(1) Expected audit_rows to equal <2 rows>, but was <3 rows>.
lengths differ: 3 items, expected 2
extra items: [3]Scoped formatters are consulted before the global ones, innermost scope
outwards, and this is the only sanctioned way to change rendering per test.
formatting() bounds how much is printed; it does not take formatters.
It never raises
Formatters are user code, and user code has bugs. One that throws is skipped
exactly as if it had declined — and so is one that returns anything but a str,
which is what a forgotten return looks like. Either way the next formatter is
asked, with no error and no warning, so if a rendering did not change, check that
format returns a string. A value nothing claims falls back to repr, and one
whose repr also throws is named by its type. Turning your failing test into an
error raised inside the assertion library is the worst outcome available.
format_value
The same function the library uses, exported so a formatter can render its own
parts through the registry — which is how a list of orders gets the order
formatter. That re-entry is bounded at a fixed depth — past it a value renders as
... — and no scope moves it: formatting(max_depth=...) bounds a difference
report, not this.