TL;DR
| Change | What it gives you |
|---|---|
| Vacuity guard | an opt-in warning when a universal assertion passes over an empty value |
| Naive and aware datetimes | fix: the ignoring-precision assertions refuse the mixed pair instead of passing |
extracting(sort=...) |
fix: an invalid sort arg is rejected rather than silently ignored |
is_subset_of() |
fix: dicts and lists as items work, the way the containment family already allowed |
| Matcher combinators | fix: & and | reject a non-matcher where the combinator is written |
| Circular references | fix: the snapshot and shape walkers report the cycle instead of a RecursionError |
| Near-timeout polls | a run-end report naming the polls that converged against their deadline, with a bar you can move |
| Snapshot mismatches | the failure names the snapshot file and the flag that accepts the new value |
| Inline snapshots | a mismatch says how to rewrite the literal in place |
assert_conforms() |
pydantic validation errors become structured diff rows, one per field |
| Soft and warn failures | the diff reaches soft_assertions() and assert_warn(), not only hard failures |
| Pipeline provenance | an empty-value failure says which step emptied it |
| Ordering and duplicates | the longest run that matched, and which values were repeated |
any_satisfy() |
the items that were examined, instead of only "none did" |
Assertions that checked nothing
A universal assertion over an empty collection is true by definition. all_satisfy() on an empty list passes without calling the predicate once, and so does every sibling that quantifies over items.
That is correct logic and a common way for a test to stop testing anything. The fixture stops producing rows, the filter stops matching, and the assertion keeps passing.
The guard is opt-in, because an empty subject is legitimate as often as it is a bug.
Turn it on with --assertpy2-vacuous or ASSERTPY2_VACUOUS=1, and silence individual call sites with allow_empty=True.
def test_archived_orders_are_settled():
archived_orders = []
assert_that(archived_orders).all_satisfy(lambda order: order.total > 0)Before
1 passed in 0.11s
Now, with the flag:
1 passed, 1 warning in 0.10s
VacuousAssertionWarning: all_satisfy() passed over an empty value, so nothing was
checked. Pass allow_empty=True if that is intended.
The warning points at the assertion line in your test, not into the library.
Guide: Assertions that checked nothing
Polls that nearly timed out
eventually() retries by design, so a retry on its own says nothing.
Spending almost the whole budget before converging is the interesting case, because that is the test that fails on a slower machine.
At the end of a run the plugin names those polls. The threshold is 70% of the timeout.
def test_worker_drains_the_queue():
started = time.monotonic()
assert_that(lambda: time.monotonic() - started > 0.4).eventually_sync().within(0.5).every(0.02).is_true()Before
2 passed in 0.54s
Now
assertpy2 polls that nearly timed out:
test_queue.py::test_worker_drains_the_queue: converged on attempt 21 at 0.41s of 0.5s (82% of the budget)
2 passed in 0.53s
Nothing fails, and nothing is collected unless the plugin is active.
On a slow box every poll converges late, so move the bar with assertpy2_poll_report or set it to off, which stops the collecting too.
[tool.pytest.ini_options]
assertpy2_poll_report = "0.9"Guide: Polls that nearly timed out
Snapshot failures name the file
A snapshot mismatch read exactly like a plain is_equal_to() failure.
It told you the two values and left you to work out which file held the stored one, and how to accept the new value.
assert_that({"id": 1, "name": "bob"}).snapshot(id="user")Before
Expected <{.., 'name': 'bob'}> to be equal to <{.., 'name': 'ann'}>, but was not.
Now
Expected <{.., 'name': 'bob'}> to be equal to <{.., 'name': 'ann'}>, but was not.
Snapshot <__snapshots\snap-user.json>; rerun with --assertpy2-snapshot-update to accept the new value.
Without a custom id the file holds one entry per line number, so the message locates the entry as __snapshots\snap-test_users.json::42.
Contract snapshots gained the same line, and an inline snapshot now says where the rewrite lands:
assert_that({"id": 1}).matches_inline("{'id': 2}")Before
Expected <{'id': 1}> to be equal to <{'id': 2}>, but was not.
Now
Expected <{'id': 1}> to be equal to <{'id': 2}>, but was not. Inline snapshot; rerun with
--assertpy2-snapshot-update to rewrite the literal here.
Guide: Snapshot testing
Failures that name what broke
Five messages stopped restating the inputs and started naming the disagreement.
assert_conforms() exposes the validation paths
A pydantic ValidationError was embedded in the message as text.
It is now also carried as structured diff rows, one per offending field, so the pytest section renders it like any other diff and .diff is readable in code.
assert_conforms({"id": "abc", "email": 42}, User)Before, the report ended at the two values:
actual: {'id': 'abc', 'email': 42}
expected: <class 'User'>
Now
actual: {'id': 'abc', 'email': 42}
expected: <class 'User'>
diff (match):
id: expected Input should be a valid integer, unable to parse string as an integer, but was 'abc'
email: expected Input should be a valid string, but was 42
Soft and warn failures carry the diff
The diff was attached to hard failures only.
Collected soft failures and assert_warn() output showed the headline alone, which is where a diff is worth the most: a soft block reports many failures at once.
with soft_assertions():
assert_that({"id": 1, "name": "ann"}).is_equal_to({"id": 1, "name": "bob"})Before
soft assertion failures:
1. Expected <{.., 'name': 'ann'}> to be equal to <{.., 'name': 'bob'}>, but was not. [test_users.py:24]
Now
soft assertion failures:
1. Expected <{.., 'name': 'ann'}> to be equal to <{.., 'name': 'bob'}>, but was not. [test_users.py:24]
name: 'ann' != 'bob'
The pipeline step that emptied the value
extracting() and filtered_on() can reduce a collection to nothing, and the failure that follows was about the empty value rather than about the step that produced it.
orders = [{"n": 1}, {"n": 2}]
assert_that(orders).filtered_on(lambda order: order["n"] > 9).is_not_empty()Before
Expected not empty, but was empty.
Now
Expected not empty, but was empty. The value is empty because filtered_on() kept 0 of 2 items.
Ordering and duplicate containment
contains_sequence() said "but did not" whether the run was almost there or absent.
does_not_contain_duplicates() never said which values repeated.
assert_that([1, 2, 5]).contains_sequence(1, 2, 3)
assert_that([1, 1, 2, 2, 3]).does_not_contain_duplicates()Before
Expected <[1, 2, 5]> to contain sequence <1, 2, 3>, but did not.
Expected <[1, 1, 2, 2, 3]> to not contain duplicates, but did.
Now
Expected <[1, 2, 5]> to contain sequence <1, 2, 3>, but did not. The longest run that matched was <1, 2>.
Expected <[1, 1, 2, 2, 3]> to not contain duplicates, but <1, 2> were repeated.
When no run could start at all, the message says so instead: No run started with <3>.
any_satisfy() shows what it examined
The universal sibling already lists every item that failed.
any_satisfy() said "none did" and left you to fetch the items yourself.
assert_that([1, 2, 3]).any_satisfy(lambda item: item > 10)Before
Expected any item to satisfy a lambda predicate, but none did.
Now
Expected any item to satisfy a lambda predicate, but none of the 3 did.
diff (match):
[0]: expected a lambda predicate, but was 1
[1]: expected a lambda predicate, but was 2
[2]: expected a lambda predicate, but was 3
The diff lists the first five items.
Fixes
A naive and an aware datetime no longer compare as equal
is_equal_to_ignoring_seconds() and its two siblings truncated both operands and compared the results, without checking that the pair was comparable. The four relational date assertions had that guard.
These three did not, so a mixed pair passed.
naive = datetime(2026, 1, 1, 12, 0, 0)
aware = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
assert_that(naive).is_equal_to_ignoring_seconds(aware)Before the assertion passed, comparing a wall-clock reading with an instant.
Now
TypeError: cannot compare a timezone-naive datetime with a timezone-aware one.
Make both aware or both naive first
An invalid extracting sort arg is rejected
sort accepts a key name, an iterable of names, or a callable.
Anything else fell through the type checks and returned the items in their original order, so the assertion ran against unsorted data and the argument was never applied.
assert_that(orders).extracting("total", sort=123)Before the items came back unsorted, with no signal.
Now
TypeError: given sort arg must be a str, iterable, or callable, but was <int>
is_subset_of() accepts unhashable items
The containment assertions fall back to linear == membership when items cannot be hashed.
is_subset_of() built a set unconditionally, so a list of dicts crashed.
assert_that([{"a": 1}]).is_subset_of([{"a": 1}, {"b": 2}])Before
TypeError: cannot use 'list' as a set element (unhashable type: 'list')
Now the assertion passes, and a genuine mismatch reports the missing items as usual:
Expected <[{'z': 9}]> to be subset of <{'a': 1}, {'b': 2}>, but <{'z': 9}> was missing.
Matcher combinators reject a non-matcher operand
& and | accepted anything.
The bad operand surfaced later, at assertion time, as an AttributeError far from the line that built the combinator.
combined = match.greater_than(3) & 5
assert_that(7).is_equal_to(combined)Before
AttributeError: 'int' object has no attribute 'matches'
Now the failure lands on the line that wrote &:
TypeError: cannot combine a Matcher with <int> using '&'
Circular references are reported, not recursed into
A value that contains itself sent the shape and snapshot walkers into unbounded recursion.
node = {"id": 1}
node["self"] = node
assert_that(node).snapshot(id="node")Before
RecursionError: maximum recursion depth exceeded
Now
ValueError: cannot snapshot a value that contains a circular reference
A contract snapshot records the cycle as <circular ref> and keeps working, since a shape does not need to unroll it.
Internal
- The
warnguide notes that output doubles whenlogging.basicConfig()is active. - Docs cover the new guard and the near-timeout report, the diff-kind table lists all nine methods that emit
matchrows, and four guide pages had their prose walls broken up. - The
tytype checker moved to 0.0.63. One call inis_child_of()needed a suppression, where it cannot bindAnyStrthrough a bareos.PathLike.