Releases: Solganis/assertpy2
Release list
v2.13.0
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 signalNow 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 typesGuide: 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-okaggregate 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
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
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
tyto 0.0.55; renamed a snapshot test off a dev-phase name.
2.10.0
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
tyto 0.0.54.
v2.9.1
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
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 raisedKeyErrorinstead 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 clearValueErrorinstead of failing withZeroDivisionErrorat 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_ofpath-boundary check,is_betweenrange-type error message, length matchers on non-Sizedvalues, structural-match headline paths, the allure diff-entry cap, single-itemcontainsdiffs, 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 overassert_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
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-matcherdictparameters are now parameterized, and the value matchers return an explicitbool. - Comparison docs rebalanced - table emphasis and trimmed slogans.
v2.8.0
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
pytestanddirty-equals.
Compatibility
- Backward compatible: failure messages are unchanged,
AssertionFailurestays anAssertionErrorsubclass, no API changes. Python 3.10+.
v2.7.0
New
returned()pivots a callable assertion onto the value the call returned. Use it afterwarns(),does_not_warn(), ordoes_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 raisesTypeErrorif 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 (expectedistype[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.
v2.6.0
New
warns()/does_not_warn()for callables: assert that calling a function emits (or does not emit) a warning, mirroringraises()/does_not_raise(). On success the matched warning message becomes the new value, so you can chain assertions on it, e.g.assert_that(func).warns(DeprecationWarning).when_called_with(x).matches("since 2.6").- The expected category defaults to
Warning(matches any warning) and matches subclasses. Unlikepytest.warns,DeprecationWarning/PendingDeprecationWarningare captured by default.
Notes
warns()/does_not_warn()are safe within a single thread (includingasynciotasks on one event loop), but not across OS threads - the same limitation aspytest.warns.