Skip to main content

lovely-assertions

Fluent, strictly-typed assertions for Python tests.

CI OpenSSF Scorecard Python Checked with pyright and mypy License: MIT

Status: 0.1.0, the first release. The catalogue, exception and warning assertions, rich differences, matchers and the extension API are in place, tested and documented. Before 1.0 the API may still move; when it does, the reason is in CHANGELOG.md, which is generated from the commit log rather than written by hand.

Install

pip install lovely-assertions

Or, with uv:

uv add --dev lovely-assertions

It has no runtime dependencies and it needs Python 3.13 or newer.

from lovely_assertions import expect

expect("hello").starts_with("he")

Why another assertion library

The competition is not assertpy or PyHamcrest — it is pytest's own assert rewriting, which already introspects assert a == b and prints a decent diff. So the value has to be somewhere else, and it is in three places. Break any one of them and the package has no reason to exist.

Typed discoverability. expect(x). offers only the assertions that are valid for the type of x. A raw assert never does that, and neither does any assertion library that dispatches dynamically.

expect("hello").starts_with("he")  # str assertions
expect([1, 2, 3]).contains_no_duplicates()
expect({"a": 1}).contains_key("a")
expect(3).is_positive()  # `starts_with` is not offered here

Real narrowing. The subject a chain returns is re-typed, statically, and both pyright and mypy agree:

raw: str | None = "ada"
payload: object = 7

name: str = expect(raw).is_not_none().subject
count: int = expect(payload).is_instance_of(int).subject

Honest limitation, stated up front: the original variable stays str | None as far as the checker is concerned. Python's TypeGuard/TypeIs can only narrow a function's first positional argument, and expect() captures the subject inside a wrapper, so the caller's variable is out of reach. Narrowing therefore flows through the returned subject — rebind it, and you have a statically guaranteed type. No Python assertion library does better; this one says so instead of pretending otherwise.

Failure messages that locate the problem. The competition prints a diff; this prints an explanation.

from lovely_assertions import soft_assertions

with soft_assertions():
    expect([3, 1, 2], name="order_totals").is_sorted()
    expect({"host": "x"}, name="server_config").contains_key("hostname")
    expect({"port": 8080}, name="config").contains_entry("port", 9090)
3 assertions failed:
  (1) Expected order_totals to be sorted, but 1 at index 1 came after 3: [3, 1, 2].
  (2) Expected server_config to contain key 'hostname' (did you mean 'host'?), but the keys were ['host'].
  (3) Expected config to contain entry 'port': 9090, but that key held 8080.

(One scope, three failures, one report — that is soft assertions, and it is why you see all three instead of only the first.)

That last pair is the point: the key holds a different value and the key is missing are different bugs, and the message says which instead of leaving you to find out. Equality on a composite value adds a difference block — a unified diff for multi-line text, the first offending index for a sequence, the keys that moved for a mapping — and it stays bounded, so comparing two five-thousand-element lists is four hundred characters, not sixty thousand.

What else you get

Exceptions, in the form you already reach for.

from lovely_assertions import expect_raises


def parse(text: str) -> int:
    return int(text)


with expect_raises(ValueError) as caught:
    parse("nope")
caught.with_message_containing("invalid literal")

When the wrong exception is raised, the failure is chained onto the real one, so its traceback survives next to the message rather than being replaced by it.

Your own assertions, with the same machinery. Subclass Expect[T], mark your methods with @custom_assertion, and they get subject naming, soft scopes, because and the whole inherited catalogue. See the extension guide.

Documentation

Full documentation →

New here Installation · Your first assertions · Reading a failure
How do I assert…? The guides — by type, and by task
Every assertion The reference, generated from the source
Why it works this way Concepts — dispatch, messages, performance, typing
Coming from assert or assertpy Migrating

Every Python example in those pages is executed by the test suite, and every failure message they quote is compared against what the library actually produces.

Design commitments

  • Zero runtime dependencies, permanently. Python 3.13+.
  • A passing assertion costs a comparison and a return self — no frame inspection, no message building, no context lookups. Failure messages are formatted only in the failure branch, never as an argument to a helper.
  • py.typed, 100% annotated, pyright strict and mypy strict both green in CI. Where mypy and pyright genuinely disagree, the divergence is documented and frozen — the API never gets shaved down to accommodate a checker.
  • The typing surface is tested like any other surface, with a negative corpus that both checkers are required to reject. Every line that must be rejected carries an expect-error marker, and the harness is symmetric: a marked line no checker reports fails the suite, and a reported line nobody marked fails it too. A harness that cannot detect a wrong assert_type proves nothing about the ones it accepts.
  • Messages are tested as output, not as behaviour. A message is not wrong for being sixty thousand characters long — no assertion fails because of it — so size and shape are pinned explicitly.

Development

uv sync

Then, all of which must be green:

uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run mypy && uv run pytest

CONTRIBUTING.md has the rest: what a change usually touches, what CI runs and what each gate proves, and how a release is cut. Taking part means agreeing to the code of conduct.

Security

Report a vulnerability privately through the Security tab, not as a public issue. SECURITY.md also sets out what this library does on the failure path — it renders your values, and it reads the source line you wrote the assertion on — so that the boundary is documented rather than discovered.

Releases are published through PyPI Trusted Publishing, with no API token anywhere in this repository, and every artifact carries a signed build provenance attestation.

License

MIT — see LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

lovely_assertions-0.1.0.tar.gz (754.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

lovely_assertions-0.1.0-py3-none-any.whl (289.9 kB view details)

Uploaded Python 3

File details

Details for the file lovely_assertions-0.1.0.tar.gz.

File metadata

  • Download URL: lovely_assertions-0.1.0.tar.gz
  • Upload date:
  • Size: 754.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for lovely_assertions-0.1.0.tar.gz
Algorithm Hash digest
SHA256 3be9872e200009657d26983f40e9a6199454453972f42a9f8d9eb18d2b762187
MD5 b6594a976f3f457c6855608b4634b99b
BLAKE2b-256 c6113e4d62dac3b94d4bf7059a6a9cec7149aabc754e2c13a8ee7508b843d23d

See more details on using hashes here.

Provenance

The following attestation bundles were made for lovely_assertions-0.1.0.tar.gz:

Publisher: release.yml on lovely-assertions/lovely-assertions

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file lovely_assertions-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for lovely_assertions-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5ba73d111bbad9385ce40682a83d2b288d117e98204982e75b0d8e296f8b31b3
MD5 06bfe0cfd4f3710ca32bbeae99f26784
BLAKE2b-256 f18d8203220b8bf8c9cda5f5e63b45da542a972a1010c6b616b3486fc9173806

See more details on using hashes here.

Provenance

The following attestation bundles were made for lovely_assertions-0.1.0-py3-none-any.whl:

Publisher: release.yml on lovely-assertions/lovely-assertions

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.0

2 files

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page