Skip to content

Releases: Solganis/assertpy2

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.

v2.5.1

Choose a tag to compare

@Solganis Solganis released this 19 Jun 17:04
8308a96

Packaging

  • typing_extensions is now installed only on Python 3.10. assertpy2 has no runtime dependencies on Python 3.11+.

Fixed

  • assertpy2.__version__ now reports the installed version (it was stale at 2.4.0).

Documentation

  • New documentation site: https://solganis.github.io/assertpy2/ - hand-written guides for assertions, matchers, the fluent API, testing, errors, extending and integrations, plus dedicated comparison and migration pages.