assertpy2 2.0.0__tar.gz → 2.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 (72) hide show
  1. assertpy2-2.1.0/.codecov.yml +10 -0
  2. {assertpy2-2.0.0 → assertpy2-2.1.0}/.github/workflows/ci.yml +5 -2
  3. assertpy2-2.1.0/CLAUDE.md +113 -0
  4. {assertpy2-2.0.0 → assertpy2-2.1.0}/PKG-INFO +180 -14
  5. {assertpy2-2.0.0 → assertpy2-2.1.0}/README.md +179 -13
  6. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/__init__.py +9 -0
  7. assertpy2-2.1.0/assertpy2/_typing.py +152 -0
  8. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/assertpy.py +136 -35
  9. assertpy2-2.1.0/assertpy2/async_assertions.py +83 -0
  10. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/base.py +127 -1
  11. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/contains.py +20 -4
  12. assertpy2-2.1.0/assertpy2/errors.py +52 -0
  13. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/helpers.py +3 -1
  14. assertpy2-2.1.0/assertpy2/matchers.py +594 -0
  15. assertpy2-2.1.0/assertpy2/pytest_plugin.py +42 -0
  16. {assertpy2-2.0.0 → assertpy2-2.1.0}/docs/api.md +270 -0
  17. assertpy2-2.1.0/docs/logo-dark.svg +5 -0
  18. {assertpy2-2.0.0 → assertpy2-2.1.0}/pyproject.toml +6 -2
  19. assertpy2-2.1.0/tests/test_async.py +157 -0
  20. assertpy2-2.1.0/tests/test_errors.py +132 -0
  21. assertpy2-2.1.0/tests/test_matchers.py +529 -0
  22. assertpy2-2.1.0/tests/test_overloads.py +73 -0
  23. assertpy2-2.1.0/tests/test_pytest_plugin.py +154 -0
  24. assertpy2-2.1.0/tests/test_structural.py +271 -0
  25. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_traceback.py +3 -2
  26. {assertpy2-2.0.0 → assertpy2-2.1.0}/uv.lock +1 -1
  27. {assertpy2-2.0.0 → assertpy2-2.1.0}/.github/workflows/publish.yml +0 -0
  28. {assertpy2-2.0.0 → assertpy2-2.1.0}/.gitignore +0 -0
  29. {assertpy2-2.0.0 → assertpy2-2.1.0}/CONTRIBUTING.md +0 -0
  30. {assertpy2-2.0.0 → assertpy2-2.1.0}/LICENSE +0 -0
  31. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/collection.py +0 -0
  32. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/date.py +0 -0
  33. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/dict.py +0 -0
  34. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/dynamic.py +0 -0
  35. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/exception.py +0 -0
  36. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/extracting.py +0 -0
  37. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/file.py +0 -0
  38. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/numeric.py +0 -0
  39. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/py.typed +0 -0
  40. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/snapshot.py +0 -0
  41. {assertpy2-2.0.0 → assertpy2-2.1.0}/assertpy2/string.py +0 -0
  42. {assertpy2-2.0.0 → assertpy2-2.1.0}/docs/logo.svg +0 -0
  43. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_bool.py +0 -0
  44. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_class.py +0 -0
  45. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_collection.py +0 -0
  46. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_core.py +0 -0
  47. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_custom_dict.py +0 -0
  48. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_custom_list.py +0 -0
  49. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_datetime.py +0 -0
  50. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_description.py +0 -0
  51. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_dict.py +0 -0
  52. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_dict_compare.py +0 -0
  53. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_dyn.py +0 -0
  54. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_equals.py +0 -0
  55. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_expected_exception.py +0 -0
  56. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_extensions.py +0 -0
  57. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_extracting.py +0 -0
  58. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_fail.py +0 -0
  59. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_file.py +0 -0
  60. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_in.py +0 -0
  61. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_list.py +0 -0
  62. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_namedtuple.py +0 -0
  63. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_none.py +0 -0
  64. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_numbers.py +0 -0
  65. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_readme.py +0 -0
  66. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_same_as.py +0 -0
  67. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_snapshots.py +0 -0
  68. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_soft.py +0 -0
  69. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_soft_fail.py +0 -0
  70. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_string.py +0 -0
  71. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_type.py +0 -0
  72. {assertpy2-2.0.0 → assertpy2-2.1.0}/tests/test_warn.py +0 -0
@@ -0,0 +1,10 @@
1
+ coverage:
2
+ status:
3
+ project:
4
+ default:
5
+ target: auto
6
+ threshold: 1%
7
+ patch:
8
+ default:
9
+ target: auto
10
+ threshold: 1%
@@ -31,11 +31,14 @@ jobs:
31
31
  run: uv run ruff check .
32
32
 
33
33
  - name: Test with coverage
34
- run: uv run pytest -v --cov=assertpy2 --cov-report=term-missing tests
34
+ run: uv run pytest -v --cov=assertpy2 --cov-report=term-missing --cov-report=xml tests
35
35
 
36
36
  - name: Upload coverage to Codecov
37
37
  if: matrix.python-version == '3.14'
38
- uses: codecov/codecov-action@v5
38
+ uses: codecov/codecov-action@v6
39
+ with:
40
+ token: ${{ secrets.CODECOV_TOKEN }}
41
+ files: coverage.xml
39
42
 
40
43
  lint:
41
44
  runs-on: ubuntu-latest
@@ -0,0 +1,113 @@
1
+ # assertpy2
2
+
3
+ Fluent assertion library for Python. Fork of assertpy с type safety, soft assertions, snapshot testing.
4
+ Python 3.10+, единственная зависимость: typing_extensions>=4.0.
5
+
6
+ ---
7
+
8
+ ## Project layout
9
+
10
+ ```
11
+ assertpy2/
12
+ ├── assertpy.py # assert_that(), AssertionBuilder, soft_assertions(), fail()
13
+ ├── async_assertions.py # AsyncAssertionBuilder - eventually() polling
14
+ ├── base.py # BaseMixin - is_equal_to, satisfies, each, matches_structure
15
+ ├── string.py # StringMixin - starts_with, ends_with, matches, is_alpha, is_digit
16
+ ├── numeric.py # NumericMixin - is_zero, is_positive, is_between, is_close_to
17
+ ├── collection.py # CollectionMixin - is_iterable, is_subset_of, is_sorted
18
+ ├── contains.py # ContainsMixin - contains, does_not_contain, is_empty
19
+ ├── dict.py # DictMixin - contains_key, contains_value, contains_entry
20
+ ├── date.py # DateMixin - is_before, is_after, is_equal_to_ignoring_time
21
+ ├── file.py # FileMixin + contents_of() - exists, is_file, is_directory
22
+ ├── exception.py # ExceptionMixin - raises, when_called_with
23
+ ├── extracting.py # ExtractingMixin - extracting with filter/sort
24
+ ├── dynamic.py # DynamicMixin - has_<attr>() via __getattr__
25
+ ├── snapshot.py # SnapshotMixin - snapshot testing
26
+ ├── helpers.py # HelpersMixin - _dict_not_equal, _fmt_items, validation
27
+ ├── errors.py # AssertionFailure, DiffResult, DiffEntry - structured errors
28
+ ├── matchers.py # Matcher protocol, composable matchers, match namespace
29
+ ├── pytest_plugin.py # pytest entry point - rich diff output for AssertionFailure
30
+ ├── _typing.py # TYPE_CHECKING-only Protocol classes for @overload return types
31
+ ├── __init__.py # Public API exports
32
+ └── py.typed # PEP 561 marker
33
+
34
+ tests/ # 100% coverage required
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Architecture
40
+
41
+ - AssertionBuilder наследует миксины. Каждый миксин - одна категория assertions.
42
+ - Все assertion-методы возвращают Self для chaining.
43
+ - error() в AssertionBuilder маршрутизирует: raise (default), log (warn), collect (soft).
44
+ - __tracebackhide__ = True во всех миксинах для чистого pytest traceback.
45
+ - Расширения через add_extension(func) - динамическая привязка через types.MethodType.
46
+
47
+ Правило: новые assertions добавляются в существующий миксин по категории.
48
+ Новый миксин создаётся только для принципиально новой категории (не для 1-2 методов).
49
+
50
+ ---
51
+
52
+ ## Tooling
53
+
54
+ ```
55
+ uv sync
56
+ uv run pytest
57
+ uv run pytest -v --cov=assertpy2 --cov-report=term-missing
58
+ uv run ruff check .
59
+ uv run ruff format .
60
+ uv run ruff format --check .
61
+ ```
62
+
63
+ ---
64
+
65
+ ## CI/CD
66
+
67
+ GitHub Actions, два workflow:
68
+
69
+ **CI** (.github/workflows/ci.yml):
70
+ - Триггер: push/PR в main
71
+ - Матрица: Python 3.10-3.15
72
+ - Шаги: uv sync, ruff check, pytest с coverage (xml + term-missing)
73
+ - Codecov upload: только с Python 3.14 (token в secrets)
74
+ - Отдельный lint job: ruff check + ruff format --check
75
+
76
+ **Publish** (.github/workflows/publish.yml):
77
+ - Триггер: GitHub Release (published)
78
+ - Trusted Publisher (id-token: write), без API-ключей
79
+ - uv build, pypa/gh-action-pypi-publish
80
+
81
+ Правила:
82
+ - Версия только в pyproject.toml (одно место)
83
+ - Релиз: обновить version в pyproject.toml, создать GitHub Release с тегом
84
+ - Не мержить без зелёного CI
85
+
86
+ ---
87
+
88
+ ## Key dependencies
89
+
90
+ | Package | Version | Role |
91
+ |---|---|---|
92
+ | typing_extensions | >=4.0 | Self type, runtime-free typing |
93
+ | pytest | >=9.0.3 | test runner (dev) |
94
+ | pytest-cov | >=6.1 | coverage (dev) |
95
+ | ruff | >=0.15.14 | linter + formatter (dev) |
96
+
97
+ ---
98
+
99
+ ## Naming
100
+
101
+ - Assertion-методы: is_*, has_*, does_not_*, contains_*, starts_with, ends_with
102
+ - Новые assertions следуют существующему паттерну: глагол + предикат
103
+ - Тестовые файлы: test_<feature>.py
104
+ - Миксины: <Category>Mixin
105
+
106
+ ---
107
+
108
+ ## Testing
109
+
110
+ - Coverage 100%. Каждый assertion-метод покрыт: happy path, error path, edge cases.
111
+ - Тесты используют pytest.raises(AssertionError) для проверки сообщений об ошибках.
112
+ - match= в pytest.raises для валидации текста ошибки.
113
+ - Snapshot-тесты хранят данные в tests/__snapshots__/.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Fluent assertion library for Python with full type safety and soft assertions
5
5
  Project-URL: Homepage, https://github.com/Solganis/assertpy2
6
6
  Project-URL: Repository, https://github.com/Solganis/assertpy2
@@ -30,11 +30,15 @@ Requires-Dist: typing-extensions>=4.0
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  <p align="center">
33
- <img src="docs/logo.svg" alt="assertpy2" width="280">
33
+ <picture>
34
+ <source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.svg">
35
+ <source media="(prefers-color-scheme: light)" srcset="docs/logo.svg">
36
+ <img src="docs/logo.svg" alt="assertpy2" width="280">
37
+ </picture>
34
38
  </p>
35
39
 
36
40
  <p align="center">
37
- <b>Fluent assertion library for Python with full type safety and soft assertions.</b><br>
41
+ <b>Fluent assertion library for Python with composable matchers, structural matching, and full type safety.</b><br>
38
42
  Maintained fork of <a href="https://github.com/assertpy/assertpy">assertpy</a>.
39
43
  </p>
40
44
 
@@ -45,6 +49,7 @@ Description-Content-Type: text/markdown
45
49
  <a href="https://pypi.org/project/assertpy2/"><img src="https://img.shields.io/pypi/pyversions/assertpy2" alt="Python"></a>
46
50
  <a href="https://codecov.io/gh/Solganis/assertpy2"><img src="https://codecov.io/gh/Solganis/assertpy2/graph/badge.svg" alt="Coverage"></a>
47
51
  <a href="https://github.com/Solganis/assertpy2/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Solganis/assertpy2" alt="License"></a>
52
+ <a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
48
53
  </p>
49
54
 
50
55
 
@@ -66,14 +71,41 @@ def test_user():
66
71
  assert_that(user).has_name("Alice")
67
72
  ```
68
73
 
69
- When an assertion fails, the error message tells you exactly what went wrong:
74
+ Composable matchers and structural matching:
70
75
 
71
76
  ```py
72
- assert_that(user["age"]).is_between(50, 120)
73
- # AssertionError: Expected <30> to be between <50> and <120>, but was not.
77
+ from assertpy2 import assert_that, match
78
+
79
+ # matchers with & | ~ operators
80
+ assert_that([3, 7, 12]).contains(match.greater_than(10))
81
+ assert_that(42).satisfies(match.greater_than(0) & match.less_than(100))
82
+
83
+ # validate dict structure declaratively
84
+ assert_that(api_response).matches_structure({
85
+ "id": match.is_uuid(),
86
+ "name": match.equal_to("Alice"),
87
+ "status": match.is_non_empty_string(),
88
+ })
89
+ ```
90
+
91
+ Structured errors with rich data:
92
+
93
+ ```py
94
+ try:
95
+ assert_that({"a": 1, "b": 2}).is_equal_to({"a": 1, "b": 99})
96
+ except AssertionError as e:
97
+ e.actual # {"a": 1, "b": 2}
98
+ e.expected # {"a": 1, "b": 99}
99
+ e.diff # DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
100
+ ```
101
+
102
+ The pytest plugin auto-renders this as rich diff sections in failure reports:
74
103
 
75
- assert_that(user["roles"]).contains("admin")
76
- # AssertionError: Expected <['viewer', 'editor']> to contain <admin>, but did not.
104
+ ```
105
+ FAILED test_example.py::test_comparison
106
+ --- AssertionFailure ---
107
+ actual: {'a': 1, 'b': 2}
108
+ expected: {'a': 1, 'b': 99}
77
109
  ```
78
110
 
79
111
 
@@ -100,9 +132,13 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
100
132
 
101
133
  ## Features
102
134
 
135
+ - **Composable matchers**: `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~` operators.
136
+ - **Structural matching**: `matches_structure()` for declarative dict/API response validation.
137
+ - **Async assertions**: `eventually()` with polling/retry for async and eventual consistency testing.
138
+ - **Structured errors**: `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
139
+ - **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
103
140
  - **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
104
- - **IDE support**: full autocomplete and chaining inference out of the box.
105
- - **Soft assertions**: collect all failures with `soft_assertions()`, plus `soft_fail()` for non-halting explicit failures.
141
+ - **Soft assertions**: thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
106
142
  - **Fluent chaining**: write assertions as readable one-liners that chain naturally.
107
143
  - **Dynamic assertions**: `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
108
144
  - **Dict comparison**: `is_equal_to()` with `ignore` and `include` for selective key matching.
@@ -113,6 +149,69 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
113
149
  - Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
114
150
 
115
151
 
152
+ ## Composable matchers
153
+
154
+ Matchers are objects that describe conditions. Combine them with `&` (and), `|` (or), `~` (not):
155
+
156
+ ```py
157
+ from assertpy2 import assert_that, match
158
+
159
+ # check a value against a composed condition
160
+ assert_that(42).satisfies(match.greater_than(0) & match.less_than(100))
161
+
162
+ # matchers inside contains - find element by condition
163
+ assert_that([3, 7, 12]).contains(match.greater_than(10))
164
+
165
+ # check every element in a collection
166
+ assert_that([18, 25, 30]).each(match.between(18, 120))
167
+
168
+ # invert with ~
169
+ assert_that("hello").satisfies(~match.equal_to("world"))
170
+
171
+ # combine freely
172
+ assert_that(150).satisfies(match.is_negative() | match.greater_than(100))
173
+ ```
174
+
175
+ Available matchers: `equal_to`, `greater_than`, `greater_than_or_equal_to`, `less_than`, `less_than_or_equal_to`, `between`, `close_to`, `is_none`, `is_not_none`, `is_instance_of`, `has_length`, `is_empty`, `is_not_empty`, `is_positive`, `is_negative`, `contains_string`, `matches_regex`, `is_uuid`, `is_non_empty_string`, `ignore`, `each_item`, `structure`.
176
+
177
+
178
+ ## Structural matching
179
+
180
+ Validate dict structure declaratively, even when values are dynamic (UUIDs, timestamps):
181
+
182
+ ```py
183
+ from assertpy2 import assert_that, match
184
+
185
+ assert_that(api_response).matches_structure({
186
+ "id": match.is_uuid(),
187
+ "name": match.equal_to("Alice"),
188
+ "created_at": match.is_non_empty_string(),
189
+ "metadata": match.structure({
190
+ "version": match.greater_than(0),
191
+ "tags": match.each_item(match.is_instance_of(str)),
192
+ }),
193
+ "debug_info": match.ignore(),
194
+ })
195
+ ```
196
+
197
+
198
+ ## Async assertions
199
+
200
+ Poll a callable until the assertion passes or timeout is reached:
201
+
202
+ ```py
203
+ from assertpy2 import assert_that
204
+
205
+ async def test_eventual_consistency():
206
+ await assert_that(get_status).eventually().within(5).every(0.5).is_equal_to("ready")
207
+
208
+ # works with async callables
209
+ await assert_that(async_get_count).eventually().within(10).is_greater_than(100)
210
+ ```
211
+
212
+ Any assertion method is available after `eventually()`. Only `AssertionError` is retried, other exceptions propagate immediately.
213
+
214
+
116
215
  ## Soft assertions
117
216
 
118
217
  Collect all failures instead of stopping at the first one:
@@ -138,8 +237,45 @@ AssertionError: soft assertion failures:
138
237
 
139
238
  Use `soft_fail("message")` inside the block for non-halting explicit failures (unlike `fail()`, which stops immediately).
140
239
 
240
+ Soft assertions are thread-safe and async-safe: each thread and each `asyncio` task gets independent state via `contextvars`.
241
+
242
+
243
+ ## Structured errors
244
+
245
+ When assertions fail, `AssertionFailure` carries structured data alongside the human-readable message:
246
+
247
+ ```py
248
+ try:
249
+ assert_that(1).is_equal_to(2)
250
+ except AssertionError as e:
251
+ e.actual # 1
252
+ e.expected # 2
253
+ ```
254
+
255
+ For dict comparisons, a `DiffResult` with per-key diff entries is available:
256
+
257
+ ```py
258
+ try:
259
+ assert_that({"a": 1, "b": 2}).is_equal_to({"a": 1, "b": 99})
260
+ except AssertionError as e:
261
+ e.diff # DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
262
+ ```
263
+
264
+ `AssertionFailure` is a subclass of `AssertionError`, so all existing `except AssertionError` handlers work unchanged.
141
265
 
142
- ## API highlights
266
+ The pytest plugin (auto-registered, no configuration needed) renders structured data as extra sections in failure reports:
267
+
268
+ ```
269
+ FAILED test_example.py::test_comparison
270
+ --- AssertionFailure ---
271
+ actual: {'a': 1, 'b': 2}
272
+ expected: {'a': 1, 'b': 99}
273
+ --- Structured Diff ---
274
+ DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
275
+ ```
276
+
277
+
278
+ ## More features
143
279
 
144
280
  ### Dict comparison with ignore/include
145
281
 
@@ -168,12 +304,37 @@ assert_that(some_func).raises(RuntimeError).when_called_with("bad_arg")\
168
304
  .is_length(8).starts_with("some").is_equal_to("some err")
169
305
  ```
170
306
 
307
+ ### Dynamic assertions
308
+
309
+ ```py
310
+ fred = {"first_name": "Fred", "last_name": "Smith", "shoe_size": 12}
311
+
312
+ assert_that(fred).has_first_name("Fred")
313
+ assert_that(fred).has_last_name("Smith")
314
+ assert_that(fred).has_shoe_size(12)
315
+ ```
316
+
171
317
  ### Snapshot testing
172
318
 
173
319
  ```py
174
320
  assert_that({"a": 1, "b": 2, "c": 3}).snapshot()
175
321
  ```
176
322
 
323
+ ### Extensions
324
+
325
+ ```py
326
+ from assertpy2 import add_extension
327
+
328
+ def is_even(self):
329
+ if self.val % 2 != 0:
330
+ return self.error(f'{self.val} is not even!')
331
+ return self
332
+
333
+ add_extension(is_even)
334
+
335
+ assert_that(4).is_even()
336
+ ```
337
+
177
338
  See the [full API reference](docs/api.md) for all assertion methods, examples, and advanced features.
178
339
 
179
340
 
@@ -195,9 +356,14 @@ from assertpy2 import assert_that, soft_assertions
195
356
  |---|---|---|
196
357
  | Maintained | Last release 2020 | Active |
197
358
  | Python | 2.7+ | 3.10-3.15 |
198
- | Type safety | No annotations | `Self` return types, `py.typed` (PEP 561) |
199
- | IDE support | No type info | Full autocomplete and chaining inference |
200
- | Soft assertions | Basic | Stable, with `soft_fail()` support |
359
+ | Type safety | No annotations | `Self` return types, `py.typed`, typed `@overload` on `assert_that()` |
360
+ | IDE support | No type info | Full autocomplete, type-specific method suggestions |
361
+ | Matchers | None | Composable matchers with `&` `\|` `~` operators |
362
+ | Structural matching | None | `matches_structure()` with recursive dict validation |
363
+ | Async | None | `eventually()` with polling/retry |
364
+ | Error reporting | Flat strings | `AssertionFailure` with `.actual`, `.expected`, `.diff` |
365
+ | Pytest integration | None | Rich diff sections in failure reports |
366
+ | Soft assertions | Global state, not thread-safe | `contextvars`, thread-safe and async-safe |
201
367
  | Security | [CVE in snapshots](https://github.com/assertpy/assertpy/issues/156) | Fixed |
202
368
  | Open bugs | 15+ unresolved | All resolved |
203
369
 
@@ -1,9 +1,13 @@
1
1
  <p align="center">
2
- <img src="docs/logo.svg" alt="assertpy2" width="280">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="docs/logo.svg">
5
+ <img src="docs/logo.svg" alt="assertpy2" width="280">
6
+ </picture>
3
7
  </p>
4
8
 
5
9
  <p align="center">
6
- <b>Fluent assertion library for Python with full type safety and soft assertions.</b><br>
10
+ <b>Fluent assertion library for Python with composable matchers, structural matching, and full type safety.</b><br>
7
11
  Maintained fork of <a href="https://github.com/assertpy/assertpy">assertpy</a>.
8
12
  </p>
9
13
 
@@ -14,6 +18,7 @@
14
18
  <a href="https://pypi.org/project/assertpy2/"><img src="https://img.shields.io/pypi/pyversions/assertpy2" alt="Python"></a>
15
19
  <a href="https://codecov.io/gh/Solganis/assertpy2"><img src="https://codecov.io/gh/Solganis/assertpy2/graph/badge.svg" alt="Coverage"></a>
16
20
  <a href="https://github.com/Solganis/assertpy2/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Solganis/assertpy2" alt="License"></a>
21
+ <a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
17
22
  </p>
18
23
 
19
24
 
@@ -35,14 +40,41 @@ def test_user():
35
40
  assert_that(user).has_name("Alice")
36
41
  ```
37
42
 
38
- When an assertion fails, the error message tells you exactly what went wrong:
43
+ Composable matchers and structural matching:
39
44
 
40
45
  ```py
41
- assert_that(user["age"]).is_between(50, 120)
42
- # AssertionError: Expected <30> to be between <50> and <120>, but was not.
46
+ from assertpy2 import assert_that, match
47
+
48
+ # matchers with & | ~ operators
49
+ assert_that([3, 7, 12]).contains(match.greater_than(10))
50
+ assert_that(42).satisfies(match.greater_than(0) & match.less_than(100))
51
+
52
+ # validate dict structure declaratively
53
+ assert_that(api_response).matches_structure({
54
+ "id": match.is_uuid(),
55
+ "name": match.equal_to("Alice"),
56
+ "status": match.is_non_empty_string(),
57
+ })
58
+ ```
59
+
60
+ Structured errors with rich data:
61
+
62
+ ```py
63
+ try:
64
+ assert_that({"a": 1, "b": 2}).is_equal_to({"a": 1, "b": 99})
65
+ except AssertionError as e:
66
+ e.actual # {"a": 1, "b": 2}
67
+ e.expected # {"a": 1, "b": 99}
68
+ e.diff # DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
69
+ ```
70
+
71
+ The pytest plugin auto-renders this as rich diff sections in failure reports:
43
72
 
44
- assert_that(user["roles"]).contains("admin")
45
- # AssertionError: Expected <['viewer', 'editor']> to contain <admin>, but did not.
73
+ ```
74
+ FAILED test_example.py::test_comparison
75
+ --- AssertionFailure ---
76
+ actual: {'a': 1, 'b': 2}
77
+ expected: {'a': 1, 'b': 99}
46
78
  ```
47
79
 
48
80
 
@@ -69,9 +101,13 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
69
101
 
70
102
  ## Features
71
103
 
104
+ - **Composable matchers**: `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~` operators.
105
+ - **Structural matching**: `matches_structure()` for declarative dict/API response validation.
106
+ - **Async assertions**: `eventually()` with polling/retry for async and eventual consistency testing.
107
+ - **Structured errors**: `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
108
+ - **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
72
109
  - **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
73
- - **IDE support**: full autocomplete and chaining inference out of the box.
74
- - **Soft assertions**: collect all failures with `soft_assertions()`, plus `soft_fail()` for non-halting explicit failures.
110
+ - **Soft assertions**: thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
75
111
  - **Fluent chaining**: write assertions as readable one-liners that chain naturally.
76
112
  - **Dynamic assertions**: `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
77
113
  - **Dict comparison**: `is_equal_to()` with `ignore` and `include` for selective key matching.
@@ -82,6 +118,69 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
82
118
  - Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
83
119
 
84
120
 
121
+ ## Composable matchers
122
+
123
+ Matchers are objects that describe conditions. Combine them with `&` (and), `|` (or), `~` (not):
124
+
125
+ ```py
126
+ from assertpy2 import assert_that, match
127
+
128
+ # check a value against a composed condition
129
+ assert_that(42).satisfies(match.greater_than(0) & match.less_than(100))
130
+
131
+ # matchers inside contains - find element by condition
132
+ assert_that([3, 7, 12]).contains(match.greater_than(10))
133
+
134
+ # check every element in a collection
135
+ assert_that([18, 25, 30]).each(match.between(18, 120))
136
+
137
+ # invert with ~
138
+ assert_that("hello").satisfies(~match.equal_to("world"))
139
+
140
+ # combine freely
141
+ assert_that(150).satisfies(match.is_negative() | match.greater_than(100))
142
+ ```
143
+
144
+ Available matchers: `equal_to`, `greater_than`, `greater_than_or_equal_to`, `less_than`, `less_than_or_equal_to`, `between`, `close_to`, `is_none`, `is_not_none`, `is_instance_of`, `has_length`, `is_empty`, `is_not_empty`, `is_positive`, `is_negative`, `contains_string`, `matches_regex`, `is_uuid`, `is_non_empty_string`, `ignore`, `each_item`, `structure`.
145
+
146
+
147
+ ## Structural matching
148
+
149
+ Validate dict structure declaratively, even when values are dynamic (UUIDs, timestamps):
150
+
151
+ ```py
152
+ from assertpy2 import assert_that, match
153
+
154
+ assert_that(api_response).matches_structure({
155
+ "id": match.is_uuid(),
156
+ "name": match.equal_to("Alice"),
157
+ "created_at": match.is_non_empty_string(),
158
+ "metadata": match.structure({
159
+ "version": match.greater_than(0),
160
+ "tags": match.each_item(match.is_instance_of(str)),
161
+ }),
162
+ "debug_info": match.ignore(),
163
+ })
164
+ ```
165
+
166
+
167
+ ## Async assertions
168
+
169
+ Poll a callable until the assertion passes or timeout is reached:
170
+
171
+ ```py
172
+ from assertpy2 import assert_that
173
+
174
+ async def test_eventual_consistency():
175
+ await assert_that(get_status).eventually().within(5).every(0.5).is_equal_to("ready")
176
+
177
+ # works with async callables
178
+ await assert_that(async_get_count).eventually().within(10).is_greater_than(100)
179
+ ```
180
+
181
+ Any assertion method is available after `eventually()`. Only `AssertionError` is retried, other exceptions propagate immediately.
182
+
183
+
85
184
  ## Soft assertions
86
185
 
87
186
  Collect all failures instead of stopping at the first one:
@@ -107,8 +206,45 @@ AssertionError: soft assertion failures:
107
206
 
108
207
  Use `soft_fail("message")` inside the block for non-halting explicit failures (unlike `fail()`, which stops immediately).
109
208
 
209
+ Soft assertions are thread-safe and async-safe: each thread and each `asyncio` task gets independent state via `contextvars`.
210
+
211
+
212
+ ## Structured errors
213
+
214
+ When assertions fail, `AssertionFailure` carries structured data alongside the human-readable message:
215
+
216
+ ```py
217
+ try:
218
+ assert_that(1).is_equal_to(2)
219
+ except AssertionError as e:
220
+ e.actual # 1
221
+ e.expected # 2
222
+ ```
223
+
224
+ For dict comparisons, a `DiffResult` with per-key diff entries is available:
225
+
226
+ ```py
227
+ try:
228
+ assert_that({"a": 1, "b": 2}).is_equal_to({"a": 1, "b": 99})
229
+ except AssertionError as e:
230
+ e.diff # DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
231
+ ```
232
+
233
+ `AssertionFailure` is a subclass of `AssertionError`, so all existing `except AssertionError` handlers work unchanged.
110
234
 
111
- ## API highlights
235
+ The pytest plugin (auto-registered, no configuration needed) renders structured data as extra sections in failure reports:
236
+
237
+ ```
238
+ FAILED test_example.py::test_comparison
239
+ --- AssertionFailure ---
240
+ actual: {'a': 1, 'b': 2}
241
+ expected: {'a': 1, 'b': 99}
242
+ --- Structured Diff ---
243
+ DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
244
+ ```
245
+
246
+
247
+ ## More features
112
248
 
113
249
  ### Dict comparison with ignore/include
114
250
 
@@ -137,12 +273,37 @@ assert_that(some_func).raises(RuntimeError).when_called_with("bad_arg")\
137
273
  .is_length(8).starts_with("some").is_equal_to("some err")
138
274
  ```
139
275
 
276
+ ### Dynamic assertions
277
+
278
+ ```py
279
+ fred = {"first_name": "Fred", "last_name": "Smith", "shoe_size": 12}
280
+
281
+ assert_that(fred).has_first_name("Fred")
282
+ assert_that(fred).has_last_name("Smith")
283
+ assert_that(fred).has_shoe_size(12)
284
+ ```
285
+
140
286
  ### Snapshot testing
141
287
 
142
288
  ```py
143
289
  assert_that({"a": 1, "b": 2, "c": 3}).snapshot()
144
290
  ```
145
291
 
292
+ ### Extensions
293
+
294
+ ```py
295
+ from assertpy2 import add_extension
296
+
297
+ def is_even(self):
298
+ if self.val % 2 != 0:
299
+ return self.error(f'{self.val} is not even!')
300
+ return self
301
+
302
+ add_extension(is_even)
303
+
304
+ assert_that(4).is_even()
305
+ ```
306
+
146
307
  See the [full API reference](docs/api.md) for all assertion methods, examples, and advanced features.
147
308
 
148
309
 
@@ -164,9 +325,14 @@ from assertpy2 import assert_that, soft_assertions
164
325
  |---|---|---|
165
326
  | Maintained | Last release 2020 | Active |
166
327
  | Python | 2.7+ | 3.10-3.15 |
167
- | Type safety | No annotations | `Self` return types, `py.typed` (PEP 561) |
168
- | IDE support | No type info | Full autocomplete and chaining inference |
169
- | Soft assertions | Basic | Stable, with `soft_fail()` support |
328
+ | Type safety | No annotations | `Self` return types, `py.typed`, typed `@overload` on `assert_that()` |
329
+ | IDE support | No type info | Full autocomplete, type-specific method suggestions |
330
+ | Matchers | None | Composable matchers with `&` `\|` `~` operators |
331
+ | Structural matching | None | `matches_structure()` with recursive dict validation |
332
+ | Async | None | `eventually()` with polling/retry |
333
+ | Error reporting | Flat strings | `AssertionFailure` with `.actual`, `.expected`, `.diff` |
334
+ | Pytest integration | None | Rich diff sections in failure reports |
335
+ | Soft assertions | Global state, not thread-safe | `contextvars`, thread-safe and async-safe |
170
336
  | Security | [CVE in snapshots](https://github.com/assertpy/assertpy/issues/156) | Fixed |
171
337
  | Open bugs | 15+ unresolved | All resolved |
172
338