Skip to content

2.18.0

Latest

Choose a tag to compare

@Solganis Solganis released this 23 Jul 16:20
0df7a8b

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 warn guide notes that output doubles when logging.basicConfig() is active.
  • Docs cover the new guard and the near-timeout report, the diff-kind table lists all nine methods that emit match rows, and four guide pages had their prose walls broken up.
  • The ty type checker moved to 0.0.63. One call in is_child_of() needed a suppression, where it cannot bind AnyStr through a bare os.PathLike.