lovely-assertions 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lovely_assertions-0.1.0/.gitignore +31 -0
- lovely_assertions-0.1.0/CHANGELOG.md +83 -0
- lovely_assertions-0.1.0/LICENSE +21 -0
- lovely_assertions-0.1.0/PKG-INFO +221 -0
- lovely_assertions-0.1.0/README.md +196 -0
- lovely_assertions-0.1.0/benchmarks/__init__.py +352 -0
- lovely_assertions-0.1.0/benchmarks/__main__.py +160 -0
- lovely_assertions-0.1.0/docs/README.md +119 -0
- lovely_assertions-0.1.0/docs/reference/README.md +65 -0
- lovely_assertions-0.1.0/fuzz/README.md +46 -0
- lovely_assertions-0.1.0/fuzz/__init__.py +6 -0
- lovely_assertions-0.1.0/fuzz/fuzz_equality.py +29 -0
- lovely_assertions-0.1.0/fuzz/fuzz_hostile.py +35 -0
- lovely_assertions-0.1.0/fuzz/fuzz_strings.py +29 -0
- lovely_assertions-0.1.0/fuzz/properties.py +213 -0
- lovely_assertions-0.1.0/pyproject.toml +348 -0
- lovely_assertions-0.1.0/scripts/generate_reference.py +1713 -0
- lovely_assertions-0.1.0/src/lovely_assertions/__init__.py +177 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_bool.py +127 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_callable.py +881 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_collection.py +1606 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_core.py +1277 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_datetime.py +1186 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_diff.py +1127 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_enum.py +404 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_equivalence.py +2613 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_exceptions.py +56 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_formatters.py +539 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_formatting.py +425 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_mapping.py +803 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_matching.py +1054 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_mock.py +748 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_names.py +438 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_numeric.py +459 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_occurrence.py +379 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_ordered.py +338 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_path.py +1126 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_reflection.py +251 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_sequence.py +816 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_string.py +1323 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_subjects.py +607 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_text.py +290 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_type.py +662 -0
- lovely_assertions-0.1.0/src/lovely_assertions/_warnings.py +683 -0
- lovely_assertions-0.1.0/src/lovely_assertions/py.typed +0 -0
- lovely_assertions-0.1.0/tests/_happy_calls.py +878 -0
- lovely_assertions-0.1.0/tests/conftest.py +234 -0
- lovely_assertions-0.1.0/tests/test_bool.py +220 -0
- lovely_assertions-0.1.0/tests/test_collection.py +2311 -0
- lovely_assertions-0.1.0/tests/test_core_additions.py +537 -0
- lovely_assertions-0.1.0/tests/test_datetime.py +1489 -0
- lovely_assertions-0.1.0/tests/test_diff.py +997 -0
- lovely_assertions-0.1.0/tests/test_dispatch_memo.py +559 -0
- lovely_assertions-0.1.0/tests/test_divergences.py +84 -0
- lovely_assertions-0.1.0/tests/test_docstring_examples.py +72 -0
- lovely_assertions-0.1.0/tests/test_documentation.py +512 -0
- lovely_assertions-0.1.0/tests/test_empty_arguments.py +231 -0
- lovely_assertions-0.1.0/tests/test_enum.py +1062 -0
- lovely_assertions-0.1.0/tests/test_equality_diffs.py +132 -0
- lovely_assertions-0.1.0/tests/test_equivalence.py +2234 -0
- lovely_assertions-0.1.0/tests/test_equivalence_assertion.py +550 -0
- lovely_assertions-0.1.0/tests/test_equivalence_torture.py +2382 -0
- lovely_assertions-0.1.0/tests/test_exceptions.py +1178 -0
- lovely_assertions-0.1.0/tests/test_extension_api.py +222 -0
- lovely_assertions-0.1.0/tests/test_failure_messages.py +110 -0
- lovely_assertions-0.1.0/tests/test_formatter_wiring.py +235 -0
- lovely_assertions-0.1.0/tests/test_formatters.py +708 -0
- lovely_assertions-0.1.0/tests/test_formatting.py +491 -0
- lovely_assertions-0.1.0/tests/test_fuzzing.py +88 -0
- lovely_assertions-0.1.0/tests/test_generic_subject.py +443 -0
- lovely_assertions-0.1.0/tests/test_happy_path.py +1100 -0
- lovely_assertions-0.1.0/tests/test_mapping.py +1345 -0
- lovely_assertions-0.1.0/tests/test_matching.py +987 -0
- lovely_assertions-0.1.0/tests/test_mock.py +1228 -0
- lovely_assertions-0.1.0/tests/test_narrowing.py +488 -0
- lovely_assertions-0.1.0/tests/test_numeric.py +1130 -0
- lovely_assertions-0.1.0/tests/test_object_diff.py +1027 -0
- lovely_assertions-0.1.0/tests/test_occurrence.py +536 -0
- lovely_assertions-0.1.0/tests/test_ordered.py +404 -0
- lovely_assertions-0.1.0/tests/test_packaging.py +392 -0
- lovely_assertions-0.1.0/tests/test_path.py +1797 -0
- lovely_assertions-0.1.0/tests/test_performance_invariants.py +1439 -0
- lovely_assertions-0.1.0/tests/test_sequence.py +1674 -0
- lovely_assertions-0.1.0/tests/test_soft_assertions.py +739 -0
- lovely_assertions-0.1.0/tests/test_source_conventions.py +162 -0
- lovely_assertions-0.1.0/tests/test_string.py +2168 -0
- lovely_assertions-0.1.0/tests/test_subject_names.py +805 -0
- lovely_assertions-0.1.0/tests/test_tier_zero.py +173 -0
- lovely_assertions-0.1.0/tests/test_type.py +999 -0
- lovely_assertions-0.1.0/tests/test_typing_surface.py +311 -0
- lovely_assertions-0.1.0/tests/test_warnings.py +1098 -0
- lovely_assertions-0.1.0/tests/test_wildcards.py +465 -0
- lovely_assertions-0.1.0/tests/test_workflow_conventions.py +316 -0
- lovely_assertions-0.1.0/typing_tests/negative/bool_negative.py +58 -0
- lovely_assertions-0.1.0/typing_tests/negative/collection_negative.py +181 -0
- lovely_assertions-0.1.0/typing_tests/negative/datetime_negative.py +188 -0
- lovely_assertions-0.1.0/typing_tests/negative/dispatch_negative.py +64 -0
- lovely_assertions-0.1.0/typing_tests/negative/enum_negative.py +131 -0
- lovely_assertions-0.1.0/typing_tests/negative/exceptions_negative.py +105 -0
- lovely_assertions-0.1.0/typing_tests/negative/extending_negative.py +66 -0
- lovely_assertions-0.1.0/typing_tests/negative/harness_sanity_negative.py +22 -0
- lovely_assertions-0.1.0/typing_tests/negative/mapping_negative.py +195 -0
- lovely_assertions-0.1.0/typing_tests/negative/matching_negative.py +120 -0
- lovely_assertions-0.1.0/typing_tests/negative/mock_negative.py +123 -0
- lovely_assertions-0.1.0/typing_tests/negative/narrowing_negative.py +103 -0
- lovely_assertions-0.1.0/typing_tests/negative/numeric_negative.py +65 -0
- lovely_assertions-0.1.0/typing_tests/negative/ordered_negative.py +126 -0
- lovely_assertions-0.1.0/typing_tests/negative/path_negative.py +118 -0
- lovely_assertions-0.1.0/typing_tests/negative/sequence_negative.py +79 -0
- lovely_assertions-0.1.0/typing_tests/negative/string_negative.py +126 -0
- lovely_assertions-0.1.0/typing_tests/negative/type_negative.py +89 -0
- lovely_assertions-0.1.0/typing_tests/negative/warnings_negative.py +114 -0
- lovely_assertions-0.1.0/typing_tests/positive/bool_subject.py +120 -0
- lovely_assertions-0.1.0/typing_tests/positive/collection.py +265 -0
- lovely_assertions-0.1.0/typing_tests/positive/continuations.py +202 -0
- lovely_assertions-0.1.0/typing_tests/positive/datetime_subject.py +207 -0
- lovely_assertions-0.1.0/typing_tests/positive/dispatch.py +304 -0
- lovely_assertions-0.1.0/typing_tests/positive/enum_subject.py +150 -0
- lovely_assertions-0.1.0/typing_tests/positive/exceptions.py +134 -0
- lovely_assertions-0.1.0/typing_tests/positive/extending.py +55 -0
- lovely_assertions-0.1.0/typing_tests/positive/harness_sanity.py +17 -0
- lovely_assertions-0.1.0/typing_tests/positive/mapping.py +233 -0
- lovely_assertions-0.1.0/typing_tests/positive/matching.py +137 -0
- lovely_assertions-0.1.0/typing_tests/positive/mock.py +173 -0
- lovely_assertions-0.1.0/typing_tests/positive/narrowing.py +92 -0
- lovely_assertions-0.1.0/typing_tests/positive/numeric.py +56 -0
- lovely_assertions-0.1.0/typing_tests/positive/ordered.py +96 -0
- lovely_assertions-0.1.0/typing_tests/positive/path.py +131 -0
- lovely_assertions-0.1.0/typing_tests/positive/sequence.py +141 -0
- lovely_assertions-0.1.0/typing_tests/positive/string.py +202 -0
- lovely_assertions-0.1.0/typing_tests/positive/type_subject.py +154 -0
- lovely_assertions-0.1.0/typing_tests/positive/warning_subject.py +148 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
|
|
8
|
+
# Environments
|
|
9
|
+
.venv/
|
|
10
|
+
|
|
11
|
+
# Tooling caches
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
.mypy_cache_negative/
|
|
16
|
+
.pyright_cache/
|
|
17
|
+
|
|
18
|
+
# Coverage. `.coverage.*` is the per-run data the matrix produces, and
|
|
19
|
+
# `coverage.xml` is what the analysis job reads -- both are build output.
|
|
20
|
+
.coverage
|
|
21
|
+
.coverage.*
|
|
22
|
+
coverage.xml
|
|
23
|
+
htmlcov/
|
|
24
|
+
|
|
25
|
+
# Claude Code local overrides (personal, never shared)
|
|
26
|
+
.claude/settings.local.json
|
|
27
|
+
CLAUDE.local.md
|
|
28
|
+
# Agent worktrees. Claude Code excludes these through `.git/info/exclude`, which
|
|
29
|
+
# is local to one clone -- so the ignore has to be here too, or the first
|
|
30
|
+
# contributor to run an isolated agent commits a second copy of the repository.
|
|
31
|
+
.claude/worktrees/
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
**This file is generated** from the commit log by
|
|
9
|
+
[git-cliff](https://git-cliff.org). Don't edit it -- write the commit message,
|
|
10
|
+
which a CI gate already requires to be a Conventional Commit.
|
|
11
|
+
|
|
12
|
+
## 0.1.0 (2026-08-30)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* fluent, strictly-typed assertions for Python tests ([c7b0448](https://github.com/lovely-assertions/lovely-assertions/commit/c7b04485d3b33c2553347d9be53b44a54ad6b378))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* correct what the pages got wrong, and run the README ([#2](https://github.com/lovely-assertions/lovely-assertions/issues/2)) ([42d3fc5](https://github.com/lovely-assertions/lovely-assertions/commit/42d3fc5a627cd89053adc0e2344a958feb9b96b2))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### CI
|
|
26
|
+
|
|
27
|
+
* **codeql:** analyse with security-extended, not security-and-quality ([#1](https://github.com/lovely-assertions/lovely-assertions/issues/1)) ([b3d5d6a](https://github.com/lovely-assertions/lovely-assertions/commit/b3d5d6a245be54e58d33896d089a9b35e2ab5b14))
|
|
28
|
+
* **release:** derive the version from the commit log with release-please ([#4](https://github.com/lovely-assertions/lovely-assertions/issues/4)) ([3c18126](https://github.com/lovely-assertions/lovely-assertions/commit/3c18126c0c6bc89a426421c11ba717aec9d49f5a))
|
|
29
|
+
* **release:** tag as `v0.1.0`, and pin what nothing else connects ([#6](https://github.com/lovely-assertions/lovely-assertions/issues/6)) ([729149c](https://github.com/lovely-assertions/lovely-assertions/commit/729149c1f5426169ea3b8fee7db0a67bb23fe004))
|
|
30
|
+
* **sonar:** harden the release gate, and scope what Sonar grades ([#3](https://github.com/lovely-assertions/lovely-assertions/issues/3)) ([30a64c4](https://github.com/lovely-assertions/lovely-assertions/commit/30a64c490d4386cb057f3af5310d4c83ec973a42))
|
|
31
|
+
|
|
32
|
+
## [0.1.0] - 2026-08-29
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- Fluent, strictly-typed assertions for Python tests
|
|
37
|
+
|
|
38
|
+
`expect(value).is_equal_to(...)`. The competition is not assertpy or
|
|
39
|
+
PyHamcrest — it is pytest's own assert rewriting, which already introspects
|
|
40
|
+
`assert a == b` and prints a decent diff. So this library claims three things
|
|
41
|
+
instead, and breaking any one of them would leave it with no reason to exist.
|
|
42
|
+
|
|
43
|
+
**Typed discoverability.** `expect(x).` offers only the assertions valid for
|
|
44
|
+
the type of `x`. A `str` subject has no `is_positive`. Dispatch is one table
|
|
45
|
+
written twice — an `@overload` chain a checker walks, and a runtime branch
|
|
46
|
+
order that walks the same one — so what you are offered and what you get
|
|
47
|
+
cannot disagree.
|
|
48
|
+
|
|
49
|
+
**Real narrowing.** `expect(raw).is_not_none().subject` is a `str` to both
|
|
50
|
+
pyright and mypy, not an `object`. The limitation is stated rather than
|
|
51
|
+
hidden: the caller's own variable stays `str | None`, because `TypeIs` can
|
|
52
|
+
only narrow a function's first positional argument and `expect()` captures
|
|
53
|
+
its subject inside a wrapper. Narrowing flows through the returned subject —
|
|
54
|
+
rebind it, and the type is statically guaranteed.
|
|
55
|
+
|
|
56
|
+
**Failure messages that explain.** A sentence naming the subject, what was
|
|
57
|
+
expected, and what was actually there:
|
|
58
|
+
|
|
59
|
+
Expected server_config to contain key 'hostname' (did you mean 'host'?),
|
|
60
|
+
but the keys were ['host'].
|
|
61
|
+
|
|
62
|
+
A missing key and a key holding the wrong value are different bugs and get
|
|
63
|
+
different sentences. Difference blocks are bounded, so comparing two very
|
|
64
|
+
large lists costs a few hundred characters rather than the lists themselves.
|
|
65
|
+
|
|
66
|
+
Subjects for strings, numbers, collections, sequences, mappings, dates and
|
|
67
|
+
times, paths, exceptions, warnings, mocks, types and enums; soft assertions,
|
|
68
|
+
asymmetric matchers, structural equivalence, occurrence counting, output
|
|
69
|
+
control, and an extension API that hands a subclass the whole inherited
|
|
70
|
+
catalogue.
|
|
71
|
+
|
|
72
|
+
Zero runtime dependencies, permanently — this package is installed into test
|
|
73
|
+
suites that already carry their own trees, and adding to theirs is a cost
|
|
74
|
+
they did not choose. Python 3.13+, `py.typed`, full type completeness,
|
|
75
|
+
pyright and mypy both strict and both green on 3.13 and 3.14.
|
|
76
|
+
|
|
77
|
+
A passing assertion costs a comparison and a `return self`: no allocation, no
|
|
78
|
+
frame inspection, no message built. The failure path may do whatever it needs
|
|
79
|
+
to explain itself.
|
|
80
|
+
|
|
81
|
+
Every Python block in the documentation is executed by the test suite and
|
|
82
|
+
every failure message it quotes is compared against what the library actually
|
|
83
|
+
prints, so a page cannot drift from the code without failing the build.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aymeric Pasco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lovely-assertions
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fluent, strictly-typed assertions for Python tests: typed discoverability, real narrowing, and failure messages that explain themselves.
|
|
5
|
+
Project-URL: Homepage, https://github.com/lovely-assertions/lovely-assertions
|
|
6
|
+
Project-URL: Repository, https://github.com/lovely-assertions/lovely-assertions
|
|
7
|
+
Project-URL: Changelog, https://github.com/lovely-assertions/lovely-assertions/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/lovely-assertions/lovely-assertions/issues
|
|
9
|
+
Author: Aymeric Pasco
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: assertions,fluent,pytest,testing,type-safe,typing
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: Pytest
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.13
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# lovely-assertions
|
|
27
|
+
|
|
28
|
+
Fluent, strictly-typed assertions for Python tests.
|
|
29
|
+
|
|
30
|
+
[](https://github.com/lovely-assertions/lovely-assertions/actions/workflows/ci.yml)
|
|
31
|
+
[](https://scorecard.dev/viewer/?uri=github.com/lovely-assertions/lovely-assertions)
|
|
32
|
+
[](https://www.python.org/)
|
|
33
|
+
[](#design-commitments)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
> **Status: 0.1.0, the first release.** The catalogue, exception and warning
|
|
37
|
+
> assertions, rich differences, matchers and the extension API are in place,
|
|
38
|
+
> tested and documented. Before 1.0 the API may still move; when it does, the
|
|
39
|
+
> reason is in [CHANGELOG.md](CHANGELOG.md), which is generated from the commit
|
|
40
|
+
> log rather than written by hand.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install lovely-assertions
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or, with uv:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv add --dev lovely-assertions
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It has no runtime dependencies and it needs Python 3.13 or newer.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from lovely_assertions import expect
|
|
58
|
+
|
|
59
|
+
expect("hello").starts_with("he")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Why another assertion library
|
|
63
|
+
|
|
64
|
+
The competition is not `assertpy` or `PyHamcrest` — it is pytest's own `assert`
|
|
65
|
+
rewriting, which already introspects `assert a == b` and prints a decent diff.
|
|
66
|
+
So the value has to be somewhere else, and it is in three places. Break any one
|
|
67
|
+
of them and the package has no reason to exist.
|
|
68
|
+
|
|
69
|
+
**Typed discoverability.** `expect(x).` offers only the assertions that are valid
|
|
70
|
+
for the type of `x`. A raw `assert` never does that, and neither does any
|
|
71
|
+
assertion library that dispatches dynamically.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
expect("hello").starts_with("he") # str assertions
|
|
75
|
+
expect([1, 2, 3]).contains_no_duplicates()
|
|
76
|
+
expect({"a": 1}).contains_key("a")
|
|
77
|
+
expect(3).is_positive() # `starts_with` is not offered here
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Real narrowing.** The subject a chain returns is re-typed, statically, and both
|
|
81
|
+
pyright and mypy agree:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
raw: str | None = "ada"
|
|
85
|
+
payload: object = 7
|
|
86
|
+
|
|
87
|
+
name: str = expect(raw).is_not_none().subject
|
|
88
|
+
count: int = expect(payload).is_instance_of(int).subject
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Honest limitation, stated up front: the *original variable* stays `str | None` as
|
|
92
|
+
far as the checker is concerned. Python's `TypeGuard`/`TypeIs` can only narrow a
|
|
93
|
+
function's first positional argument, and `expect()` captures the subject inside
|
|
94
|
+
a wrapper, so the caller's variable is out of reach. Narrowing therefore flows
|
|
95
|
+
through the returned subject — rebind it, and you have a statically guaranteed
|
|
96
|
+
type. No Python assertion library does better; this one says so instead of
|
|
97
|
+
pretending otherwise.
|
|
98
|
+
|
|
99
|
+
**Failure messages that locate the problem.** The competition prints a diff; this
|
|
100
|
+
prints an explanation.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from lovely_assertions import soft_assertions
|
|
104
|
+
|
|
105
|
+
with soft_assertions():
|
|
106
|
+
expect([3, 1, 2], name="order_totals").is_sorted()
|
|
107
|
+
expect({"host": "x"}, name="server_config").contains_key("hostname")
|
|
108
|
+
expect({"port": 8080}, name="config").contains_entry("port", 9090)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
3 assertions failed:
|
|
113
|
+
(1) Expected order_totals to be sorted, but 1 at index 1 came after 3: [3, 1, 2].
|
|
114
|
+
(2) Expected server_config to contain key 'hostname' (did you mean 'host'?), but the keys were ['host'].
|
|
115
|
+
(3) Expected config to contain entry 'port': 9090, but that key held 8080.
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
(One scope, three failures, one report — that is
|
|
119
|
+
[soft assertions](docs/guides/soft-assertions.md), and it is why you see all
|
|
120
|
+
three instead of only the first.)
|
|
121
|
+
|
|
122
|
+
That last pair is the point: *the key holds a different value* and *the key is
|
|
123
|
+
missing* are different bugs, and the message says which instead of leaving you to
|
|
124
|
+
find out. Equality on a composite value adds a difference block — a unified diff
|
|
125
|
+
for multi-line text, the first offending index for a sequence, the keys that moved
|
|
126
|
+
for a mapping — and it stays bounded, so comparing two five-thousand-element lists
|
|
127
|
+
is four hundred characters, not sixty thousand.
|
|
128
|
+
|
|
129
|
+
## What else you get
|
|
130
|
+
|
|
131
|
+
**Exceptions, in the form you already reach for.**
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from lovely_assertions import expect_raises
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse(text: str) -> int:
|
|
138
|
+
return int(text)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
with expect_raises(ValueError) as caught:
|
|
142
|
+
parse("nope")
|
|
143
|
+
caught.with_message_containing("invalid literal")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
When the wrong exception is raised, the failure is chained onto the real one, so
|
|
147
|
+
its traceback survives next to the message rather than being replaced by it.
|
|
148
|
+
|
|
149
|
+
**Your own assertions, with the same machinery.** Subclass `Expect[T]`, mark your
|
|
150
|
+
methods with `@custom_assertion`, and they get subject naming, soft scopes,
|
|
151
|
+
`because` and the whole inherited catalogue. See
|
|
152
|
+
[the extension guide](docs/guides/extending.md).
|
|
153
|
+
|
|
154
|
+
## Documentation
|
|
155
|
+
|
|
156
|
+
**[Full documentation →](docs/README.md)**
|
|
157
|
+
|
|
158
|
+
| | |
|
|
159
|
+
|---|---|
|
|
160
|
+
| New here | [Installation](docs/getting-started/installation.md) · [Your first assertions](docs/getting-started/first-assertions.md) · [Reading a failure](docs/getting-started/reading-failures.md) |
|
|
161
|
+
| How do I assert…? | [The guides](docs/README.md#guides) — by type, and by task |
|
|
162
|
+
| Every assertion | [The reference](docs/reference/assertions.md), generated from the source |
|
|
163
|
+
| Why it works this way | [Concepts](docs/README.md#concepts) — dispatch, messages, performance, typing |
|
|
164
|
+
| Coming from `assert` or `assertpy` | [Migrating](docs/guides/migrating.md) |
|
|
165
|
+
|
|
166
|
+
Every Python example in those pages is executed by the test suite, and every
|
|
167
|
+
failure message they quote is compared against what the library actually
|
|
168
|
+
produces.
|
|
169
|
+
|
|
170
|
+
## Design commitments
|
|
171
|
+
|
|
172
|
+
- **Zero runtime dependencies**, permanently. Python 3.13+.
|
|
173
|
+
- **A passing assertion costs a comparison and a `return self`** — no frame
|
|
174
|
+
inspection, no message building, no context lookups. Failure messages are
|
|
175
|
+
formatted only in the failure branch, never as an argument to a helper.
|
|
176
|
+
- **`py.typed`, 100% annotated, pyright strict and mypy strict both green in CI.**
|
|
177
|
+
Where mypy and pyright genuinely disagree, the divergence is documented and
|
|
178
|
+
frozen — the API never gets shaved down to accommodate a checker.
|
|
179
|
+
- **The typing surface is tested like any other surface**, with a negative corpus
|
|
180
|
+
that both checkers are required to reject. Every line that must be rejected
|
|
181
|
+
carries an `expect-error` marker, and the harness is symmetric: a marked line
|
|
182
|
+
no checker reports fails the suite, and a reported line nobody marked fails it
|
|
183
|
+
too. A harness that cannot detect a wrong `assert_type` proves nothing about
|
|
184
|
+
the ones it accepts.
|
|
185
|
+
- **Messages are tested as output, not as behaviour.** A message is not wrong for
|
|
186
|
+
being sixty thousand characters long — no assertion fails because of it — so
|
|
187
|
+
size and shape are pinned explicitly.
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
uv sync
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Then, all of which must be green:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run mypy && uv run pytest
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) has the rest: what a change usually touches,
|
|
202
|
+
what CI runs and what each gate proves, and how a release is cut. Taking part
|
|
203
|
+
means agreeing to the [code of conduct](CODE_OF_CONDUCT.md).
|
|
204
|
+
|
|
205
|
+
## Security
|
|
206
|
+
|
|
207
|
+
Report a vulnerability privately through the
|
|
208
|
+
[Security tab](https://github.com/lovely-assertions/lovely-assertions/security),
|
|
209
|
+
not as a public issue. [SECURITY.md](SECURITY.md) also sets out what this library
|
|
210
|
+
does on the failure path — it renders your values, and it reads the source line
|
|
211
|
+
you wrote the assertion on — so that the boundary is documented rather than
|
|
212
|
+
discovered.
|
|
213
|
+
|
|
214
|
+
Releases are published through PyPI
|
|
215
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/), with no API
|
|
216
|
+
token anywhere in this repository, and every artifact carries a signed build
|
|
217
|
+
provenance attestation.
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# lovely-assertions
|
|
2
|
+
|
|
3
|
+
Fluent, strictly-typed assertions for Python tests.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/lovely-assertions/lovely-assertions/actions/workflows/ci.yml)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/lovely-assertions/lovely-assertions)
|
|
7
|
+
[](https://www.python.org/)
|
|
8
|
+
[](#design-commitments)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
|
|
11
|
+
> **Status: 0.1.0, the first release.** The catalogue, exception and warning
|
|
12
|
+
> assertions, rich differences, matchers and the extension API are in place,
|
|
13
|
+
> tested and documented. Before 1.0 the API may still move; when it does, the
|
|
14
|
+
> reason is in [CHANGELOG.md](CHANGELOG.md), which is generated from the commit
|
|
15
|
+
> log rather than written by hand.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install lovely-assertions
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or, with uv:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv add --dev lovely-assertions
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
It has no runtime dependencies and it needs Python 3.13 or newer.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from lovely_assertions import expect
|
|
33
|
+
|
|
34
|
+
expect("hello").starts_with("he")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Why another assertion library
|
|
38
|
+
|
|
39
|
+
The competition is not `assertpy` or `PyHamcrest` — it is pytest's own `assert`
|
|
40
|
+
rewriting, which already introspects `assert a == b` and prints a decent diff.
|
|
41
|
+
So the value has to be somewhere else, and it is in three places. Break any one
|
|
42
|
+
of them and the package has no reason to exist.
|
|
43
|
+
|
|
44
|
+
**Typed discoverability.** `expect(x).` offers only the assertions that are valid
|
|
45
|
+
for the type of `x`. A raw `assert` never does that, and neither does any
|
|
46
|
+
assertion library that dispatches dynamically.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
expect("hello").starts_with("he") # str assertions
|
|
50
|
+
expect([1, 2, 3]).contains_no_duplicates()
|
|
51
|
+
expect({"a": 1}).contains_key("a")
|
|
52
|
+
expect(3).is_positive() # `starts_with` is not offered here
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Real narrowing.** The subject a chain returns is re-typed, statically, and both
|
|
56
|
+
pyright and mypy agree:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
raw: str | None = "ada"
|
|
60
|
+
payload: object = 7
|
|
61
|
+
|
|
62
|
+
name: str = expect(raw).is_not_none().subject
|
|
63
|
+
count: int = expect(payload).is_instance_of(int).subject
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Honest limitation, stated up front: the *original variable* stays `str | None` as
|
|
67
|
+
far as the checker is concerned. Python's `TypeGuard`/`TypeIs` can only narrow a
|
|
68
|
+
function's first positional argument, and `expect()` captures the subject inside
|
|
69
|
+
a wrapper, so the caller's variable is out of reach. Narrowing therefore flows
|
|
70
|
+
through the returned subject — rebind it, and you have a statically guaranteed
|
|
71
|
+
type. No Python assertion library does better; this one says so instead of
|
|
72
|
+
pretending otherwise.
|
|
73
|
+
|
|
74
|
+
**Failure messages that locate the problem.** The competition prints a diff; this
|
|
75
|
+
prints an explanation.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from lovely_assertions import soft_assertions
|
|
79
|
+
|
|
80
|
+
with soft_assertions():
|
|
81
|
+
expect([3, 1, 2], name="order_totals").is_sorted()
|
|
82
|
+
expect({"host": "x"}, name="server_config").contains_key("hostname")
|
|
83
|
+
expect({"port": 8080}, name="config").contains_entry("port", 9090)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
3 assertions failed:
|
|
88
|
+
(1) Expected order_totals to be sorted, but 1 at index 1 came after 3: [3, 1, 2].
|
|
89
|
+
(2) Expected server_config to contain key 'hostname' (did you mean 'host'?), but the keys were ['host'].
|
|
90
|
+
(3) Expected config to contain entry 'port': 9090, but that key held 8080.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
(One scope, three failures, one report — that is
|
|
94
|
+
[soft assertions](docs/guides/soft-assertions.md), and it is why you see all
|
|
95
|
+
three instead of only the first.)
|
|
96
|
+
|
|
97
|
+
That last pair is the point: *the key holds a different value* and *the key is
|
|
98
|
+
missing* are different bugs, and the message says which instead of leaving you to
|
|
99
|
+
find out. Equality on a composite value adds a difference block — a unified diff
|
|
100
|
+
for multi-line text, the first offending index for a sequence, the keys that moved
|
|
101
|
+
for a mapping — and it stays bounded, so comparing two five-thousand-element lists
|
|
102
|
+
is four hundred characters, not sixty thousand.
|
|
103
|
+
|
|
104
|
+
## What else you get
|
|
105
|
+
|
|
106
|
+
**Exceptions, in the form you already reach for.**
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from lovely_assertions import expect_raises
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse(text: str) -> int:
|
|
113
|
+
return int(text)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
with expect_raises(ValueError) as caught:
|
|
117
|
+
parse("nope")
|
|
118
|
+
caught.with_message_containing("invalid literal")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
When the wrong exception is raised, the failure is chained onto the real one, so
|
|
122
|
+
its traceback survives next to the message rather than being replaced by it.
|
|
123
|
+
|
|
124
|
+
**Your own assertions, with the same machinery.** Subclass `Expect[T]`, mark your
|
|
125
|
+
methods with `@custom_assertion`, and they get subject naming, soft scopes,
|
|
126
|
+
`because` and the whole inherited catalogue. See
|
|
127
|
+
[the extension guide](docs/guides/extending.md).
|
|
128
|
+
|
|
129
|
+
## Documentation
|
|
130
|
+
|
|
131
|
+
**[Full documentation →](docs/README.md)**
|
|
132
|
+
|
|
133
|
+
| | |
|
|
134
|
+
|---|---|
|
|
135
|
+
| New here | [Installation](docs/getting-started/installation.md) · [Your first assertions](docs/getting-started/first-assertions.md) · [Reading a failure](docs/getting-started/reading-failures.md) |
|
|
136
|
+
| How do I assert…? | [The guides](docs/README.md#guides) — by type, and by task |
|
|
137
|
+
| Every assertion | [The reference](docs/reference/assertions.md), generated from the source |
|
|
138
|
+
| Why it works this way | [Concepts](docs/README.md#concepts) — dispatch, messages, performance, typing |
|
|
139
|
+
| Coming from `assert` or `assertpy` | [Migrating](docs/guides/migrating.md) |
|
|
140
|
+
|
|
141
|
+
Every Python example in those pages is executed by the test suite, and every
|
|
142
|
+
failure message they quote is compared against what the library actually
|
|
143
|
+
produces.
|
|
144
|
+
|
|
145
|
+
## Design commitments
|
|
146
|
+
|
|
147
|
+
- **Zero runtime dependencies**, permanently. Python 3.13+.
|
|
148
|
+
- **A passing assertion costs a comparison and a `return self`** — no frame
|
|
149
|
+
inspection, no message building, no context lookups. Failure messages are
|
|
150
|
+
formatted only in the failure branch, never as an argument to a helper.
|
|
151
|
+
- **`py.typed`, 100% annotated, pyright strict and mypy strict both green in CI.**
|
|
152
|
+
Where mypy and pyright genuinely disagree, the divergence is documented and
|
|
153
|
+
frozen — the API never gets shaved down to accommodate a checker.
|
|
154
|
+
- **The typing surface is tested like any other surface**, with a negative corpus
|
|
155
|
+
that both checkers are required to reject. Every line that must be rejected
|
|
156
|
+
carries an `expect-error` marker, and the harness is symmetric: a marked line
|
|
157
|
+
no checker reports fails the suite, and a reported line nobody marked fails it
|
|
158
|
+
too. A harness that cannot detect a wrong `assert_type` proves nothing about
|
|
159
|
+
the ones it accepts.
|
|
160
|
+
- **Messages are tested as output, not as behaviour.** A message is not wrong for
|
|
161
|
+
being sixty thousand characters long — no assertion fails because of it — so
|
|
162
|
+
size and shape are pinned explicitly.
|
|
163
|
+
|
|
164
|
+
## Development
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
uv sync
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Then, all of which must be green:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run mypy && uv run pytest
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) has the rest: what a change usually touches,
|
|
177
|
+
what CI runs and what each gate proves, and how a release is cut. Taking part
|
|
178
|
+
means agreeing to the [code of conduct](CODE_OF_CONDUCT.md).
|
|
179
|
+
|
|
180
|
+
## Security
|
|
181
|
+
|
|
182
|
+
Report a vulnerability privately through the
|
|
183
|
+
[Security tab](https://github.com/lovely-assertions/lovely-assertions/security),
|
|
184
|
+
not as a public issue. [SECURITY.md](SECURITY.md) also sets out what this library
|
|
185
|
+
does on the failure path — it renders your values, and it reads the source line
|
|
186
|
+
you wrote the assertion on — so that the boundary is documented rather than
|
|
187
|
+
discovered.
|
|
188
|
+
|
|
189
|
+
Releases are published through PyPI
|
|
190
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/), with no API
|
|
191
|
+
token anywhere in this repository, and every artifact carries a signed build
|
|
192
|
+
provenance attestation.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT — see [LICENSE](LICENSE).
|