Skip to content

Releases: Solganis/assertpy2

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.

v2.6.0

Choose a tag to compare

@Solganis Solganis released this 20 Jun 18:25
361b338

New

  • warns() / does_not_warn() for callables: assert that calling a function emits (or does not emit) a warning, mirroring raises() / 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. Unlike pytest.warns, DeprecationWarning / PendingDeprecationWarning are captured by default.

Notes

  • warns() / does_not_warn() are safe within a single thread (including asyncio tasks on one event loop), but not across OS threads - the same limitation as pytest.warns.