lovely-assertions
Fluent, strictly-typed assertions for Python tests.
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
| 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-errormarker, 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 wrongassert_typeproves 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3be9872e200009657d26983f40e9a6199454453972f42a9f8d9eb18d2b762187
|
|
| MD5 |
b6594a976f3f457c6855608b4634b99b
|
|
| BLAKE2b-256 |
c6113e4d62dac3b94d4bf7059a6a9cec7149aabc754e2c13a8ee7508b843d23d
|
Provenance
The following attestation bundles were made for lovely_assertions-0.1.0.tar.gz:
Publisher:
release.yml on lovely-assertions/lovely-assertions
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
lovely_assertions-0.1.0.tar.gz -
Subject digest:
3be9872e200009657d26983f40e9a6199454453972f42a9f8d9eb18d2b762187 - Sigstore transparency entry: 2655064539
- Sigstore integration time:
-
Permalink:
lovely-assertions/lovely-assertions@7ec63315f8b2427d20a2c593c5b5be00d4725ab2 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/lovely-assertions
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7ec63315f8b2427d20a2c593c5b5be00d4725ab2 -
Trigger Event:
push
-
Statement type:
File details
Details for the file lovely_assertions-0.1.0-py3-none-any.whl.
File metadata
- Download URL: lovely_assertions-0.1.0-py3-none-any.whl
- Upload date:
- Size: 289.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5ba73d111bbad9385ce40682a83d2b288d117e98204982e75b0d8e296f8b31b3
|
|
| MD5 |
06bfe0cfd4f3710ca32bbeae99f26784
|
|
| BLAKE2b-256 |
f18d8203220b8bf8c9cda5f5e63b45da542a972a1010c6b616b3486fc9173806
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
lovely_assertions-0.1.0-py3-none-any.whl -
Subject digest:
5ba73d111bbad9385ce40682a83d2b288d117e98204982e75b0d8e296f8b31b3 - Sigstore transparency entry: 2655064554
- Sigstore integration time:
-
Permalink:
lovely-assertions/lovely-assertions@7ec63315f8b2427d20a2c593c5b5be00d4725ab2 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/lovely-assertions
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7ec63315f8b2427d20a2c593c5b5be00d4725ab2 -
Trigger Event:
push
-
Statement type: