Skip to content

Releases: Solganis/assertpy2

v2.14.0

Choose a tag to compare

@Solganis Solganis released this 03 Jul 16:13
7786e32

TL;DR

Added What it gives you
PollTrace on eventually() timeouts a flight recorder for polling: per-poll timeline in the message, on failure.trace, and in Allure
eventually() honors soft/warn timeouts inside soft_assertions() are collected, not raised mid-block
Typed Allure attachments values as native JSON (4, not "4"): a collapsible tree with a format: 2 marker
Typed ordering on str / bytes / dates is_greater_than and friends no longer flagged by type checkers
is_array_equal(**options) numpy options pass through, mirroring is_frame_equal()

Features

A flight recorder for eventually()

Before, a polling timeout told you only that time ran out -
debugging a flaky eventual-consistency test meant re-running it with hand-rolled logging:

await assert_that(get_order).eventually(timeout=5, ignoring=ConnectionError).is_equal_to({"status": "PAID"})
AssertionError: Expected condition not met after 5.0 seconds. Last failure: Expected <{'status': 'SHIPPED'}>
to be equal to <{'status': 'PAID'}>, but was not.

Now every poll is recorded and the failure diagnoses itself.
The message carries a one-line trend that separates
"the service never came up" from "it converged to the wrong value" from "the timeout is too short":

AssertionError: Expected condition not met after 5.0 seconds (probe recovered after 2 raising polls;
value then changed 1 time). Last failure: Expected <{'status': 'SHIPPED'}> to be equal to <{'status': 'PAID'}>,
but was not.

The raised AssertionFailure carries the full timeline as failure.trace -
a PollTrace of point-in-time samples, with identical consecutive polls collapsed.
The pytest report gets a Polling Trace section:

polled 9 times over 5.0s; probe recovered after 2 raising polls; value then changed 1 time
  t=+0.0s error x2: ConnectionError('boot')
  t=+0.5s fail x2: Expected <{'status': 'PENDING'}> to be equal to <{'status': 'PAID'}>, but was not.
  t=+1.5s fail x5: Expected <{'status': 'SHIPPED'}> to be equal to <{'status': 'PAID'}>, but was not.

and Allure receives a typed Polling Trace JSON attachment including diffs between consecutive distinct samples.
Long polls keep the first 5 and last 20 samples.
Values are snapshotted per poll, so a probe that mutates and returns the same object still yields an honest timeline.

Guide: Polling trace

eventually() honors soft and warn modes

Before, a polling timeout inside soft_assertions() raised hard and aborted the block - the collector never saw it:

with soft_assertions():
    await assert_that(get_status).eventually(timeout=5).is_equal_to("ready")
    assert_that(response.code).is_equal_to(200)  # never reached
AssertionError: Expected condition not met after 5.0 seconds. Last failure: ...

Now the polling loop is unchanged - the probe still retries until the timeout (note the 10 polls below).
Only the final timeout failure follows the builder's mode:
inside soft_assertions() it is collected and the block continues; under assert_warn() it is logged instead of raised.

AssertionError: soft assertion failures:
1. Expected condition not met after 5.0 seconds (value unchanged across 10 polls). Last failure: ...
2. Expected <404> to be equal to <200>, but was not.

Guide: Async assertions

Typed JSON in Allure attachments

Before, attachment values were Python repr strings - flat text in the Allure viewer, unparseable for tooling:

{"path": "servings", "actual": "4", "expected": "6"}

Now values are native JSON rendered as a collapsible tree.
Both attachments carry a schema marker ("format": 2; attachments without the field are the older repr format).
Anything JSON cannot express degrades to a marked fallback instead of losing the attachment -
including values whose __repr__ raises, which previously dropped the attachment silently:

{
  "format": 2,
  "kind": "dict",
  "entries": [
    {"path": "servings", "actual": 4, "expected": 6},
    {"path": "created", "actual": {"__repr__": "datetime.datetime(2026, 7, 2, 10, 0)"}, "expected": null}
  ]
}

Strings are capped at 4000 chars and containers at 100 items, so a huge payload no longer produces a 100 KB attachment.
If you parse these attachments, branch on the format field.

Guide: Allure attachments

Typed ordering on strings, bytes, and dates

Before, assert_that("banana").is_greater_than("apple") worked at runtime but was flagged
by ty, mypy, and pyright - the ordering comparators were only declared for numbers.

Now the typed surface mirrors the runtime:
the four ordering comparators are declared on str and bytes/bytearray (lexicographic),
and on date/datetime together with is_between (chronological):

assert_that("banana").is_greater_than("apple")          # clean under ty / mypy --strict / pyright
assert_that(b"b").is_less_than(bytearray(b"c"))
assert_that(date(2026, 1, 2)).is_between(date(2026, 1, 1), date(2026, 1, 3))

This closes the gap reported in assertpy #128.

Guide: Type safety

numpy options pass through the array assertions

Before, is_array_equal() accepted no options:

assert_that(actual).is_array_equal(expected, strict=True)
TypeError: DataFrameMixin.is_array_equal() got an unexpected keyword argument 'strict'

Now keyword options forward to numpy's assert_array_equal / assert_allclose, mirroring is_frame_equal():

assert_that(actual).is_array_equal(expected, strict=True)       # adds numpy's dtype check
assert_that(actual).is_array_close_to(expected, rtol=1e-3, err_msg="drift too large")

Guide: Data frames and arrays

Fixes

Nested arrays raise the actionable error in is_not_equal_to() and matches_structure()

Before, the nested array guard shipped in 2.13.0 covered is_equal_to() only -
the sibling methods still crashed with the raw ambiguity error:

assert_that({"a": np.array([1, 2])}).is_not_equal_to({"a": np.array([1, 3])})
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

Now both raise the same actionable TypeError naming the method, at any nesting depth:

TypeError: is_not_equal_to() cannot directly compare <ndarray>: its '==' is element-wise and has no
single truth value. ...

A matcher-wrapped array inside matches_structure() records a mismatch instead of crashing.

Error rendering survives a broken __repr__

Before, a value whose __repr__ raises crashed the failure rendering itself -
the test died with the user's exception instead of the assertion failure:

assert_that({"a": BadRepr(), "b": 1}).is_equal_to({"a": BadRepr(), "b": 2})
RuntimeError: broken repr

Now the assertion failure renders with a placeholder:

AssertionError: Expected <{'a': <unreprable BadRepr>, 'b': 1}> to be equal to <{'a': <unreprable BadRepr>, 'b': 2}>, but was not.

Failure text is capped for huge values

Before, a 10,000-entry mismatch produced a 235,000-character exception message flooding the CI log.

Now rendered operands are capped at 4000 chars and str(diff) at 50 entries (... and 9950 more entries).
The structured payload (failure.actual / .expected / .diff.entries) always keeps the full data:

message: 235,607 chars  ->  8,089 chars

Snapshots keep datetime microseconds

Before, sub-second precision was silently dropped on capture, so the second run falsely failed:

Expected <2026-01-01 12:00:00.123456> to be equal to <2026-01-01 12:00:00>, but was not.

Now microseconds round-trip; snapshots without sub-second precision keep the old on-disk format, so existing files stay readable.

Warn mode survives unlocatable caller frames

Before, running warn-mode assertions from a user file living under a directory named assertpy2
crashed with TypeError: cannot unpack non-iterable NoneType object.
Now the warning is logged without the location prefix.

not_.eventually() fails with a clear error

Before, negating eventually() always failed with the misleading Expected <...> to NOT satisfy: eventually().
Now it raises TypeError: eventually() cannot be negated with not_; assert the inverted condition instead.

Internal

  • perf: user extensions now bind once at registration instead of on every assert_that() call (20 extensions: 4.9x per-call overhead eliminated); as a side effect, extensions are visible to builders created before registration.
  • perf: protocol introspection is skipped for exact builtin types - all_fields_satisfy on a 10k-leaf payload dropped from 53 ms to 19 ms, has_no_none_fields from 41 ms to 6.5 ms.
  • refactor: the recursive diff engine moved from base.py i...
Read more

v2.13.0

Choose a tag to compare

@Solganis Solganis released this 02 Jul 03:21
0ed3200

TL;DR

Added What it gives you
eventually(ignoring=...) / .ignoring() polling retries through "not ready yet" exceptions (a ConnectionError while a service boots), not only failing assertions
snapshot(ignore=..., tolerance=..., ...) snapshots that survive volatile fields and float noise: the same selective options as is_equal_to(), while the file keeps the full value
SnapshotCreatedWarning a first snapshot capture is no longer silent; under -W error it fails explicitly

Features

Retry ignored exceptions while polling

In eventual-consistency tests, "not ready yet" usually manifests as an exception, not a wrong value.

Before, any exception raised by the probe aborted polling on the first call:

async def get_order():
    return await api.get_order(order_id)  # raises ConnectionError while the service boots

await assert_that(get_order).eventually(timeout=10).is_equal_to("PAID")
ConnectionError: service not ready

Now list the exception types to retry, as a kwarg or fluently (the default stays strict, and only
Exception subclasses are accepted, so KeyboardInterrupt can never be swallowed):

await assert_that(get_order).eventually(timeout=10, ignoring=ConnectionError).is_equal_to("PAID")

await assert_that(get_order).eventually().within(10).ignoring(ConnectionError, TimeoutError).is_equal_to("PAID")

On timeout, the last ignored exception is reported with its type and chained for context:

AssertionError: Expected condition not met after 10.0 seconds. Last failure: ConnectionError('service not ready')

Guide: Async assertions

Selective compare options in snapshot()

Before, a timestamp or generated id in the payload broke the snapshot on every run - the comparison was all-or-nothing:

assert_that(api_response).snapshot(id="order", ignore="created_at")
TypeError: SnapshotMixin.snapshot() got an unexpected keyword argument 'ignore'

Now the comparison accepts the same ignore / include / tolerance / comparators options as is_equal_to().
The snapshot file always stores the full value; the options only shape the comparison:

assert_that(api_response).snapshot(id="order", ignore=["created_at", ("user", "session_id")])
assert_that(metrics).snapshot(id="latency", tolerance=0.001)
assert_that(payload).snapshot(id="user", comparators={"name": lambda a, e: a.lower() == e.lower()})

Guide: Volatile fields and float noise

First snapshot capture warns

Before, the first run of a snapshot assertion silently wrote the file and passed - a wrong first capture became the reference without anyone noticing:

assert_that(payload).snapshot(id="order")  # first run: passes, no signal

Now every fresh capture emits a SnapshotCreatedWarning:

SnapshotCreatedWarning: created snapshot <__snapshots/snap-order.json>: this run captured the value
instead of comparing; subsequent runs compare against it (delete the file to re-capture)

Suites running with -W error (or filterwarnings = ["error"]) turn a new capture into an explicit failure, which is usually what you want in CI.

Guide: Snapshot testing

Fixes

Arrays and frames nested inside containers raise the actionable error

Before, is_equal_to() rejected a top-level numpy array / DataFrame with a clear TypeError,
but the same value nested inside a dict, list, dataclass, or model crashed with the raw ambiguity error - even when both sides were equal:

assert_that({"a": np.array([1, 2])}).is_equal_to({"a": np.array([1, 2])})
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

Now the guard applies at any nesting depth and points at the guilty member:

TypeError: is_equal_to() cannot directly compare <ndarray>: its '==' is element-wise and has no single
truth value. Compare the value's own equality (e.g. assert_that(actual.equals(expected)).is_true()),
assert on extracted scalars (columns, shape, length), or use satisfies(...) with an explicit predicate.

Comparison errors not caused by an array/frame member propagate unchanged.

Matcher == never raises

Before, comparing a matcher against an operand its predicate could not evaluate leaked an exception - dangerous for the drop-in == style, where a matcher can meet foreign values in membership checks:

match.is_positive() == "admin"
TypeError: '>' not supported between instances of 'str' and 'int'

Now an operand the predicate cannot evaluate simply compares as not equal:

assert (match.is_positive() == "admin") is False
assert match.is_positive() in [None, "x", 5]  # safe with mixed types

Guide: Drop-in with plain ==

Models nested inside dicts match structurally and keep leaf paths

Before, a pydantic-style model nested inside a plain dict failed a plain-dict spec even when every value matched, and structured-diff entries collapsed to the parent key:

assert_that({"address": address_model}).matches_structure({"address": {"city": "NY"}})
AssertionError: Expected <...> to match structure a dict matching structure {address: {city: <NY>}},
but at <address>: expected a dict, but was <<Address object at 0x...>>.

Now models are normalized at every level, so the spec above matches, and a genuine mismatch
reports the joined leaf path in both the message and the structured diff:

AssertionError: Expected <...> to match structure a dict matching structure {address: a dict matching
structure {city: <LA>}}, but at <address.city>: expected <LA>, but was <NY>.

Guide: Structural matching

Internal

  • ci: the Codecov cell now installs pandas, polars, numpy, pydantic, attrs, and requests, so the integration tests previously skipped in CI run against the real libraries.
  • ci: dependabot switched to the uv ecosystem with grouped updates; added a ci-ok aggregate gate job.
  • build: pandas in the lockfile stepped off the yanked 3.0.4 release; expanded PyPI keywords; bumped ty to 0.0.56.
  • test: warnings are now errors suite-wide; markers are strict.
  • chore: enabled additional ruff rule groups (C4, PIE, PGH, ASYNC, PLE, T10).
  • docs: renamed the Reference nav section to API Reference; documented the nested-guard, matcher-equality, and per-level model-normalization guarantees.

2.12.0

Choose a tag to compare

@Solganis Solganis released this 29 Jun 17:52
003a169

TL;DR

Added What it gives you
is_equal_to(tolerance=, comparators=) Float tolerance and custom comparators anywhere in nested equality; ignore/include now also take re.Pattern / type
all_fields_satisfy(), has_no_none_fields() One matcher or callable applied to every scalar leaf of an object graph
satisfies_exactly(), zip_satisfies(), contains_only_once(), has_same_size_as() Positional, pairwise, once-only, and size-parity iterable assertions

Recursive comparison configuration on is_equal_to

is_equal_to() gains tolerance (absolute, applied to every real-number leaf at any depth) and comparators (keyed by a type or a field name, mapping to an (actual, expected) -> bool predicate); ignore/include now also accept a re.Pattern (matched against field names) and a type (matched against field values). Tolerated or comparator-equal leaves appear in neither the message nor the diff.

Guide: Recursive comparison (tolerance / comparators)

Before - nested floats never compare equal under ==, and there was no way to apply a tolerance to a leaf inside a structure:

assert_that({"point": {"x": 0.1 + 0.2}}).is_equal_to({"point": {"x": 0.3}})
Expected <{'point': {'x': 0.30000000000000004}}> to be equal to <{'point': {'x': 0.3}}>, but was not.
diff (dict):
  point.x:
    - 0.30000000000000004
    + 0.3

Now - an absolute tolerance settles float drift anywhere in the graph, comparators apply custom equality per type or field, and ignore drops volatile fields:

assert_that({"point": {"x": 0.1 + 0.2}}).is_equal_to({"point": {"x": 0.3}}, tolerance=1e-9)
assert_that(order).is_equal_to(expected, comparators={"name": lambda actual, expected: actual.lower() == expected.lower()})

import re
assert_that(payload).is_equal_to(expected, ignore=[re.compile(r"^_"), float])

Recursive leaf assertions

all_fields_satisfy() applies one matcher or callable to every scalar leaf of an object graph (mappings, dataclasses, namedtuples, Pydantic models, lists, tuples), reporting the path of each leaf that fails. has_no_none_fields() is the common special case.

Guide: Recursive field assertions

Before - no recursive leaf assertion; you walked the structure by hand and asserted field by field.

Now:

assert_that({"a": 1, "nested": {"b": 2}}).all_fields_satisfy(match.is_positive())
assert_that({"id": 1, "profile": {"name": "Alice"}}).has_no_none_fields()

assert_that({"a": 1, "b": {"c": -2}}).all_fields_satisfy(match.is_positive())
Expected all fields to satisfy a positive value, but 1 field did not.
diff (match):
  b.c: expected a positive value, but was -2

Iterable-assertion cluster

Four positional/pairwise iterable assertions: satisfies_exactly() (the i-th item satisfies the i-th matcher, lengths must match), zip_satisfies() (a two-arg predicate over items zipped with another iterable), contains_only_once() (each given item occurs exactly once), and has_same_size_as() (length parity with another sized object).

Guide: Lists & iterables

Before - none of these existed.

Now:

assert_that([1, "foo", 3.0]).satisfies_exactly(match.is_odd(), match.is_instance_of(str), match.is_positive())
assert_that([1, 2, 3]).zip_satisfies([2, 4, 6], lambda actual, other: other == actual * 2)
assert_that([1, 2, 3]).contains_only_once(1, 3)
assert_that([1, 2, 3]).has_same_size_as(("a", "b", "c"))

Every failure is reported at the element path, for example:

Expected items to satisfy the given matchers in order, but 1 item did not.
diff (match):
  [1]: expected an instance of <int>, but was 'foo'
Expected <[1, 2, 2, 3]> to contain <2> only once, but contained <2> more than once.

Documentation

  • New generated API reference (mkdocstrings) covering every assertion, matcher, and entry point.
  • Documentation site restructured into Introduction / Getting started / Guides / Concepts / Extending / Reference, with improved dark-mode contrast and a landing-page grid.

Internal

  • Mutation-testing matrix (cosmic-ray) expanded across more modules; coverage hardened against surviving mutants.
  • Dependency floors refreshed.

2.11.0

Choose a tag to compare

@Solganis Solganis released this 27 Jun 12:42
810e445

TL;DR

Added What it gives you
is_frame_equal() Fluent equality for pandas / polars DataFrame / Series, delegating to the library's own assert_frame_equal and carrying its diff on failure
is_array_equal(), is_array_close_to() Exact and tolerant numpy-array equality via assert_array_equal / assert_allclose

Data-frame and array assertions

Fluent equality for pandas / polars DataFrame / Series (is_frame_equal()) and numpy arrays (is_array_equal(), is_array_close_to()), delegating comparison semantics to each library's own assert_frame_equal / assert_allclose and carrying its diff on failure. Optional extra: pip install assertpy2[pandas] (or [polars], [numpy], [data]).

Guide: Data frames and arrays

Before - these assertions did not exist:

AttributeError: assertpy has no assertion <is_frame_equal()>

Now:

assert_that(df).is_frame_equal(expected, check_dtype=False)
assert_that(arr).is_array_close_to(expected, rtol=1e-3)

Richer dict diffs

A failing is_equal_to() on a dict now decomposes nested dataclasses, models, namedtuples and nested lists to the exact differing path (matching the detail already shown for top-level values), and dicts with mixed-type keys no longer raise.

Guide: Rich pytest diffs

@dataclass
class Point:
    x: int
    y: int

assert_that({"point": Point(1, 2)}).is_equal_to({"point": Point(1, 3)})

Before - the nested object was reported as one leaf:

point:
  - Point(x=1, y=2)
  + Point(x=1, y=3)

Now - decomposed to the exact differing path:

point.y:
  - 2
  + 3

Clear error when comparing array/frame-likes

is_equal_to() / is_not_equal_to() on a numpy array or pandas/polars frame now raise a clear, actionable TypeError instead of the library's cryptic "ambiguous truth value".

assert_that(df).is_equal_to(other)

Before - the underlying library's element-wise == leaked through:

ValueError: The truth value of a DataFrame is ambiguous. Use a.empty, a.bool(), ...

Now - a clear, actionable error pointing at the right tools:

TypeError: is_equal_to() cannot directly compare <DataFrame>: its '==' is element-wise
and has no single truth value. Compare the value's own equality (e.g.
assert_that(actual.equals(expected)).is_true()), assert on extracted scalars
(columns, shape, length), or use satisfies(...) with an explicit predicate.

Internal

  • Restructured the README integrations section (compact, linked) and added a data-frame row to the comparison table.
  • Bumped dev type-checker ty to 0.0.55; renamed a snapshot test off a dev-phase name.

2.10.0

Choose a tag to compare

@Solganis Solganis released this 26 Jun 13:40
d7211c9

TL;DR

Added What it gives you
matches_structure() Structural matching (also satisfies(match.structure(...)), each(...), and the == form) accepts a Pydantic v2 model directly, with a path-level diff
extracting() Pull attributes straight off Pydantic v2 model instances

Pydantic v2 models in structural matching

matches_structure(), satisfies(match.structure(...)), each(...), and the == form now accept a Pydantic v2 model directly (via model_dump()) and report a path-level diff.

Guide: Structural matching

class User(BaseModel):
    name: str
    role: str

user = User(name="Alice", role="superadmin")

Before - a model was rejected; you had to call .model_dump() yourself:

TypeError: val must be a dict

Now - the model is accepted directly and reports a path-level diff:

assert_that(user).matches_structure({"role": match.is_in("admin", "user")})
role: expected a value in <('admin', 'user')>, but was 'superadmin'

Pydantic v2 models in extracting()

extracting() pulls attributes straight off model instances (models are iterable but not subscriptable).

Guide: Extracting attributes from objects

users = [User(name="Alice", role="admin"), User(name="Bob", role="editor")]

Before - extracting off a list of models raised:

TypeError: item <User> does not have [] accessor

Now - attributes are pulled directly off each model:

assert_that(users).extracting("name").contains("Alice", "Bob")
assert_that(users).extracting("name", "role").is_equal_to(
    [("Alice", "admin"), ("Bob", "editor")]
)

Richer nested diffs

Nested sequences and dataclass fields in a failing is_equal_to() are now decomposed to the exact differing path, matching the detail already shown at the top level.

Guide: Rich pytest diffs

@dataclass
class Matrix:
    rows: list[list[int]]

assert_that(Matrix([[1, 2], [3, 4]])).is_equal_to(Matrix([[1, 2], [3, 9]]))

Before - the whole nested list was reported as one leaf:

.rows:
  - [[1, 2], [3, 4]]
  + [[1, 2], [3, 9]]

Now - decomposed to the exact index:

.rows[1][1]:
  - 4
  + 9

Internal

  • Closed mutation-testing gaps: hardened the rich-diff ordering guards and file.is_named.
  • Refreshed diff screenshots and docs; bumped dev type-checker ty to 0.0.54.

v2.9.1

Choose a tag to compare

@Solganis Solganis released this 25 Jun 14:39
05b07d8

match.structure() no longer reports a false circular reference for shared sub-objects

When a spec or value shared one sub-object instance across two keys (a DAG, not a cycle), satisfies(match.structure(...)), each(...), and the == form failed incorrectly with a false circular-reference error. matches_structure() was unaffected. The matcher now scopes its visited-set per path, so shared sub-objects match while genuine cycles are still detected.

Guide: Structural matching

Before - reusing one nested instance under sibling keys was misreported as a circular reference, failing the match.

Now - shared sub-objects (a DAG) match correctly; only genuine cycles are flagged.

Internal

  • The structure matcher's two parallel traversals were merged into one, with no behavior change beyond the fix.
  • Plus documentation and test-suite housekeeping. No public API changes.

v2.9.0

Choose a tag to compare

@Solganis Solganis released this 25 Jun 09:33
c832c81

TL;DR

Added What it gives you
is_equal_to(ignore=, include=) Selective field comparison now also accepts set / frozenset, not just list/tuple
is_before(), is_after() Date assertions accept datetime subclasses (third-party datetime libraries, test fakes)

set / frozenset in selective comparison

is_equal_to(..., ignore=) and include= now accept set and frozenset. Selective field comparison previously required a list or tuple of keys.

Guide: Selective comparison (ignore / include)

assert_that(actual).is_equal_to(expected, ignore={"created_at", "id"})

Before - a set was rejected; only list or tuple were accepted.

Now - sets and frozensets work, as shown above.

datetime subclasses in date assertions

is_before(), is_after(), is_equal_to_ignoring_*, and is_close_to now treat instances of datetime subclasses (e.g. third-party datetime libraries and test fakes) as valid datetimes instead of rejecting them on an exact-type check.

Guide: Dates

Before - a datetime subclass instance was rejected on an exact-type check.

Now - subclass instances are accepted as valid datetimes.

Fixed

  • is_subset_of() against a single-key superset dict raised KeyError instead of a clean assertion: a value mismatch against a one-entry mapping crashed while formatting the failure message. It now reports the mismatch normally.
  • is_divisible_by() matcher rejects a zero divisor with a clear ValueError instead of failing with ZeroDivisionError at match time.
  • Parallel-safe snapshots. Snapshot writes are serialized with a file lock and the snapshot directory is created race-free, so parallel test runs no longer collide on snapshot files.
  • eventually() awaits awaitables returned by synchronous callables, so a plain function that returns a coroutine is handled correctly.
  • Plus smaller correctness fixes: is_child_of path-boundary check, is_between range-type error message, length matchers on non-Sized values, structural-match headline paths, the allure diff-entry cap, single-item contains diffs, and several failure-message wording fixes.

Internal

  • Test-suite hardening driven by mutation testing (cosmic-ray) closed real gaps across the date, collection, matchers, bytes, dict, numeric, and string assertions.
  • A weekly mutation-testing workflow and a typed-overload cross-check (ty + mypy --strict + pyright over assert_that) were added to CI.
  • Shared-helper refactors (dict-like checks, datetime formatting, collection guards) and dependency bumps. No public API changes beyond the above.

v2.8.1

Choose a tag to compare

@Solganis Solganis released this 22 Jun 03:54
89e763e

starts_with() and ends_with() accept generators

starts_with() and ends_with() on a generator or any other non-Sized iterable previously raised TypeError from an internal len() check. They now consume the iterable correctly, matching the documented "string or iterable" contract.

Guide: Strings

Before - calling either on a generator raised TypeError from an internal len() check.

Now - the iterable is consumed correctly and the assertion passes:

assert_that(x for x in [1, 2, 3]).starts_with(1)

Internal

  • Type-checker alignment with no public API or behavior change: assert_that's overload implementation is annotated against the shared base protocol (clearing the overload-consistency diagnostics), structure-matcher dict parameters are now parameterized, and the value matchers return an explicit bool.
  • Comparison docs rebalanced - table emphasis and trimmed slogans.

v2.8.0

Choose a tag to compare

@Solganis Solganis released this 20 Jun 22:35
4f7e932

Path-level diffs for matcher assertions

When matches_structure(), satisfies(), or each() fail, the pytest plugin now renders a structured match diff pointing at the exact path of every failing field and the predicate that failed - not just the first mismatch. The failure also carries structured data (.actual / .expected / .diff with kind="match"), so the breakdown flows into Allure attachments.

Guide: Rich pytest diffs

Before - these assertions raised a plain AssertionError with no structured diff, stopping at the first mismatch.

Now - every failing field is reported at its exact path:

diff (match):
  user.name: expected a non-empty string, but was ''
  user.role: expected a value in <('admin', 'user')>, but was 'superadmin'
  user.age: expected a value between <18> and <120>, but was 15

Documentation

  • Failure output is now shown throughout the docs - landing page, README, comparison, matchers, errors, and getting started - including a side-by-side "when it fails" comparison against plain pytest and dirty-equals.

Compatibility

  • Backward compatible: failure messages are unchanged, AssertionFailure stays an AssertionError subclass, no API changes. Python 3.10+.

v2.7.0

Choose a tag to compare

@Solganis Solganis released this 20 Jun 19:52
9ba2a7e

New

  • returned() pivots a callable assertion onto the value the call returned. Use it after warns(), does_not_warn(), or does_not_raise() to assert on the return value in the same chain:
    assert_that(make_client).warns(DeprecationWarning).when_called_with().returned().is_instance_of(Client). It raises TypeError if the call raised (no return value to inspect).

Improved

  • when_called_with() is now typed to return a string assertion, so chaining .matches() / .starts_with() on a captured exception or warning message type-checks (it already worked at runtime).
  • Corrected the internal builder() type stub (expected is type[BaseException] | None).
  • Added Hypothesis property-based tests (dev-only) covering equality, ignore/include (incl. nested paths and dataclasses/namedtuples), collection multiset/ordering semantics, and matcher algebra.