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.
Files changed (132) hide show
  1. lovely_assertions-0.1.0/.gitignore +31 -0
  2. lovely_assertions-0.1.0/CHANGELOG.md +83 -0
  3. lovely_assertions-0.1.0/LICENSE +21 -0
  4. lovely_assertions-0.1.0/PKG-INFO +221 -0
  5. lovely_assertions-0.1.0/README.md +196 -0
  6. lovely_assertions-0.1.0/benchmarks/__init__.py +352 -0
  7. lovely_assertions-0.1.0/benchmarks/__main__.py +160 -0
  8. lovely_assertions-0.1.0/docs/README.md +119 -0
  9. lovely_assertions-0.1.0/docs/reference/README.md +65 -0
  10. lovely_assertions-0.1.0/fuzz/README.md +46 -0
  11. lovely_assertions-0.1.0/fuzz/__init__.py +6 -0
  12. lovely_assertions-0.1.0/fuzz/fuzz_equality.py +29 -0
  13. lovely_assertions-0.1.0/fuzz/fuzz_hostile.py +35 -0
  14. lovely_assertions-0.1.0/fuzz/fuzz_strings.py +29 -0
  15. lovely_assertions-0.1.0/fuzz/properties.py +213 -0
  16. lovely_assertions-0.1.0/pyproject.toml +348 -0
  17. lovely_assertions-0.1.0/scripts/generate_reference.py +1713 -0
  18. lovely_assertions-0.1.0/src/lovely_assertions/__init__.py +177 -0
  19. lovely_assertions-0.1.0/src/lovely_assertions/_bool.py +127 -0
  20. lovely_assertions-0.1.0/src/lovely_assertions/_callable.py +881 -0
  21. lovely_assertions-0.1.0/src/lovely_assertions/_collection.py +1606 -0
  22. lovely_assertions-0.1.0/src/lovely_assertions/_core.py +1277 -0
  23. lovely_assertions-0.1.0/src/lovely_assertions/_datetime.py +1186 -0
  24. lovely_assertions-0.1.0/src/lovely_assertions/_diff.py +1127 -0
  25. lovely_assertions-0.1.0/src/lovely_assertions/_enum.py +404 -0
  26. lovely_assertions-0.1.0/src/lovely_assertions/_equivalence.py +2613 -0
  27. lovely_assertions-0.1.0/src/lovely_assertions/_exceptions.py +56 -0
  28. lovely_assertions-0.1.0/src/lovely_assertions/_formatters.py +539 -0
  29. lovely_assertions-0.1.0/src/lovely_assertions/_formatting.py +425 -0
  30. lovely_assertions-0.1.0/src/lovely_assertions/_mapping.py +803 -0
  31. lovely_assertions-0.1.0/src/lovely_assertions/_matching.py +1054 -0
  32. lovely_assertions-0.1.0/src/lovely_assertions/_mock.py +748 -0
  33. lovely_assertions-0.1.0/src/lovely_assertions/_names.py +438 -0
  34. lovely_assertions-0.1.0/src/lovely_assertions/_numeric.py +459 -0
  35. lovely_assertions-0.1.0/src/lovely_assertions/_occurrence.py +379 -0
  36. lovely_assertions-0.1.0/src/lovely_assertions/_ordered.py +338 -0
  37. lovely_assertions-0.1.0/src/lovely_assertions/_path.py +1126 -0
  38. lovely_assertions-0.1.0/src/lovely_assertions/_reflection.py +251 -0
  39. lovely_assertions-0.1.0/src/lovely_assertions/_sequence.py +816 -0
  40. lovely_assertions-0.1.0/src/lovely_assertions/_string.py +1323 -0
  41. lovely_assertions-0.1.0/src/lovely_assertions/_subjects.py +607 -0
  42. lovely_assertions-0.1.0/src/lovely_assertions/_text.py +290 -0
  43. lovely_assertions-0.1.0/src/lovely_assertions/_type.py +662 -0
  44. lovely_assertions-0.1.0/src/lovely_assertions/_warnings.py +683 -0
  45. lovely_assertions-0.1.0/src/lovely_assertions/py.typed +0 -0
  46. lovely_assertions-0.1.0/tests/_happy_calls.py +878 -0
  47. lovely_assertions-0.1.0/tests/conftest.py +234 -0
  48. lovely_assertions-0.1.0/tests/test_bool.py +220 -0
  49. lovely_assertions-0.1.0/tests/test_collection.py +2311 -0
  50. lovely_assertions-0.1.0/tests/test_core_additions.py +537 -0
  51. lovely_assertions-0.1.0/tests/test_datetime.py +1489 -0
  52. lovely_assertions-0.1.0/tests/test_diff.py +997 -0
  53. lovely_assertions-0.1.0/tests/test_dispatch_memo.py +559 -0
  54. lovely_assertions-0.1.0/tests/test_divergences.py +84 -0
  55. lovely_assertions-0.1.0/tests/test_docstring_examples.py +72 -0
  56. lovely_assertions-0.1.0/tests/test_documentation.py +512 -0
  57. lovely_assertions-0.1.0/tests/test_empty_arguments.py +231 -0
  58. lovely_assertions-0.1.0/tests/test_enum.py +1062 -0
  59. lovely_assertions-0.1.0/tests/test_equality_diffs.py +132 -0
  60. lovely_assertions-0.1.0/tests/test_equivalence.py +2234 -0
  61. lovely_assertions-0.1.0/tests/test_equivalence_assertion.py +550 -0
  62. lovely_assertions-0.1.0/tests/test_equivalence_torture.py +2382 -0
  63. lovely_assertions-0.1.0/tests/test_exceptions.py +1178 -0
  64. lovely_assertions-0.1.0/tests/test_extension_api.py +222 -0
  65. lovely_assertions-0.1.0/tests/test_failure_messages.py +110 -0
  66. lovely_assertions-0.1.0/tests/test_formatter_wiring.py +235 -0
  67. lovely_assertions-0.1.0/tests/test_formatters.py +708 -0
  68. lovely_assertions-0.1.0/tests/test_formatting.py +491 -0
  69. lovely_assertions-0.1.0/tests/test_fuzzing.py +88 -0
  70. lovely_assertions-0.1.0/tests/test_generic_subject.py +443 -0
  71. lovely_assertions-0.1.0/tests/test_happy_path.py +1100 -0
  72. lovely_assertions-0.1.0/tests/test_mapping.py +1345 -0
  73. lovely_assertions-0.1.0/tests/test_matching.py +987 -0
  74. lovely_assertions-0.1.0/tests/test_mock.py +1228 -0
  75. lovely_assertions-0.1.0/tests/test_narrowing.py +488 -0
  76. lovely_assertions-0.1.0/tests/test_numeric.py +1130 -0
  77. lovely_assertions-0.1.0/tests/test_object_diff.py +1027 -0
  78. lovely_assertions-0.1.0/tests/test_occurrence.py +536 -0
  79. lovely_assertions-0.1.0/tests/test_ordered.py +404 -0
  80. lovely_assertions-0.1.0/tests/test_packaging.py +392 -0
  81. lovely_assertions-0.1.0/tests/test_path.py +1797 -0
  82. lovely_assertions-0.1.0/tests/test_performance_invariants.py +1439 -0
  83. lovely_assertions-0.1.0/tests/test_sequence.py +1674 -0
  84. lovely_assertions-0.1.0/tests/test_soft_assertions.py +739 -0
  85. lovely_assertions-0.1.0/tests/test_source_conventions.py +162 -0
  86. lovely_assertions-0.1.0/tests/test_string.py +2168 -0
  87. lovely_assertions-0.1.0/tests/test_subject_names.py +805 -0
  88. lovely_assertions-0.1.0/tests/test_tier_zero.py +173 -0
  89. lovely_assertions-0.1.0/tests/test_type.py +999 -0
  90. lovely_assertions-0.1.0/tests/test_typing_surface.py +311 -0
  91. lovely_assertions-0.1.0/tests/test_warnings.py +1098 -0
  92. lovely_assertions-0.1.0/tests/test_wildcards.py +465 -0
  93. lovely_assertions-0.1.0/tests/test_workflow_conventions.py +316 -0
  94. lovely_assertions-0.1.0/typing_tests/negative/bool_negative.py +58 -0
  95. lovely_assertions-0.1.0/typing_tests/negative/collection_negative.py +181 -0
  96. lovely_assertions-0.1.0/typing_tests/negative/datetime_negative.py +188 -0
  97. lovely_assertions-0.1.0/typing_tests/negative/dispatch_negative.py +64 -0
  98. lovely_assertions-0.1.0/typing_tests/negative/enum_negative.py +131 -0
  99. lovely_assertions-0.1.0/typing_tests/negative/exceptions_negative.py +105 -0
  100. lovely_assertions-0.1.0/typing_tests/negative/extending_negative.py +66 -0
  101. lovely_assertions-0.1.0/typing_tests/negative/harness_sanity_negative.py +22 -0
  102. lovely_assertions-0.1.0/typing_tests/negative/mapping_negative.py +195 -0
  103. lovely_assertions-0.1.0/typing_tests/negative/matching_negative.py +120 -0
  104. lovely_assertions-0.1.0/typing_tests/negative/mock_negative.py +123 -0
  105. lovely_assertions-0.1.0/typing_tests/negative/narrowing_negative.py +103 -0
  106. lovely_assertions-0.1.0/typing_tests/negative/numeric_negative.py +65 -0
  107. lovely_assertions-0.1.0/typing_tests/negative/ordered_negative.py +126 -0
  108. lovely_assertions-0.1.0/typing_tests/negative/path_negative.py +118 -0
  109. lovely_assertions-0.1.0/typing_tests/negative/sequence_negative.py +79 -0
  110. lovely_assertions-0.1.0/typing_tests/negative/string_negative.py +126 -0
  111. lovely_assertions-0.1.0/typing_tests/negative/type_negative.py +89 -0
  112. lovely_assertions-0.1.0/typing_tests/negative/warnings_negative.py +114 -0
  113. lovely_assertions-0.1.0/typing_tests/positive/bool_subject.py +120 -0
  114. lovely_assertions-0.1.0/typing_tests/positive/collection.py +265 -0
  115. lovely_assertions-0.1.0/typing_tests/positive/continuations.py +202 -0
  116. lovely_assertions-0.1.0/typing_tests/positive/datetime_subject.py +207 -0
  117. lovely_assertions-0.1.0/typing_tests/positive/dispatch.py +304 -0
  118. lovely_assertions-0.1.0/typing_tests/positive/enum_subject.py +150 -0
  119. lovely_assertions-0.1.0/typing_tests/positive/exceptions.py +134 -0
  120. lovely_assertions-0.1.0/typing_tests/positive/extending.py +55 -0
  121. lovely_assertions-0.1.0/typing_tests/positive/harness_sanity.py +17 -0
  122. lovely_assertions-0.1.0/typing_tests/positive/mapping.py +233 -0
  123. lovely_assertions-0.1.0/typing_tests/positive/matching.py +137 -0
  124. lovely_assertions-0.1.0/typing_tests/positive/mock.py +173 -0
  125. lovely_assertions-0.1.0/typing_tests/positive/narrowing.py +92 -0
  126. lovely_assertions-0.1.0/typing_tests/positive/numeric.py +56 -0
  127. lovely_assertions-0.1.0/typing_tests/positive/ordered.py +96 -0
  128. lovely_assertions-0.1.0/typing_tests/positive/path.py +131 -0
  129. lovely_assertions-0.1.0/typing_tests/positive/sequence.py +141 -0
  130. lovely_assertions-0.1.0/typing_tests/positive/string.py +202 -0
  131. lovely_assertions-0.1.0/typing_tests/positive/type_subject.py +154 -0
  132. 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
+ [![CI](https://github.com/lovely-assertions/lovely-assertions/actions/workflows/ci.yml/badge.svg)](https://github.com/lovely-assertions/lovely-assertions/actions/workflows/ci.yml)
31
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lovely-assertions/lovely-assertions/badge)](https://scorecard.dev/viewer/?uri=github.com/lovely-assertions/lovely-assertions)
32
+ [![Python](https://img.shields.io/badge/python-3.13%20%7C%203.14-blue)](https://www.python.org/)
33
+ [![Checked with pyright and mypy](https://img.shields.io/badge/types-pyright%20%2B%20mypy%20strict-2a6db2)](#design-commitments)
34
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
+ [![CI](https://github.com/lovely-assertions/lovely-assertions/actions/workflows/ci.yml/badge.svg)](https://github.com/lovely-assertions/lovely-assertions/actions/workflows/ci.yml)
6
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lovely-assertions/lovely-assertions/badge)](https://scorecard.dev/viewer/?uri=github.com/lovely-assertions/lovely-assertions)
7
+ [![Python](https://img.shields.io/badge/python-3.13%20%7C%203.14-blue)](https://www.python.org/)
8
+ [![Checked with pyright and mypy](https://img.shields.io/badge/types-pyright%20%2B%20mypy%20strict-2a6db2)](#design-commitments)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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).