assertpy2 2.0.1__tar.gz → 2.1.1__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.1/CLAUDE.md +113 -0
  2. {assertpy2-2.0.1 → assertpy2-2.1.1}/PKG-INFO +174 -13
  3. {assertpy2-2.0.1 → assertpy2-2.1.1}/README.md +173 -12
  4. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/__init__.py +9 -0
  5. assertpy2-2.1.1/assertpy2/_typing.py +152 -0
  6. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/assertpy.py +137 -36
  7. assertpy2-2.1.1/assertpy2/async_assertions.py +83 -0
  8. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/base.py +127 -1
  9. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/contains.py +20 -4
  10. assertpy2-2.1.1/assertpy2/errors.py +52 -0
  11. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/helpers.py +3 -1
  12. assertpy2-2.1.1/assertpy2/matchers.py +594 -0
  13. assertpy2-2.1.1/assertpy2/pytest_plugin.py +42 -0
  14. {assertpy2-2.0.1 → assertpy2-2.1.1}/docs/api.md +270 -0
  15. {assertpy2-2.0.1 → assertpy2-2.1.1}/pyproject.toml +6 -2
  16. assertpy2-2.1.1/tests/test_async.py +157 -0
  17. assertpy2-2.1.1/tests/test_errors.py +132 -0
  18. assertpy2-2.1.1/tests/test_matchers.py +529 -0
  19. assertpy2-2.1.1/tests/test_overloads.py +73 -0
  20. assertpy2-2.1.1/tests/test_pytest_plugin.py +154 -0
  21. assertpy2-2.1.1/tests/test_structural.py +271 -0
  22. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_traceback.py +3 -2
  23. {assertpy2-2.0.1 → assertpy2-2.1.1}/uv.lock +1 -1
  24. {assertpy2-2.0.1 → assertpy2-2.1.1}/.codecov.yml +0 -0
  25. {assertpy2-2.0.1 → assertpy2-2.1.1}/.github/workflows/ci.yml +0 -0
  26. {assertpy2-2.0.1 → assertpy2-2.1.1}/.github/workflows/publish.yml +0 -0
  27. {assertpy2-2.0.1 → assertpy2-2.1.1}/.gitignore +0 -0
  28. {assertpy2-2.0.1 → assertpy2-2.1.1}/CONTRIBUTING.md +0 -0
  29. {assertpy2-2.0.1 → assertpy2-2.1.1}/LICENSE +0 -0
  30. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/collection.py +0 -0
  31. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/date.py +0 -0
  32. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/dict.py +0 -0
  33. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/dynamic.py +0 -0
  34. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/exception.py +0 -0
  35. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/extracting.py +0 -0
  36. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/file.py +0 -0
  37. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/numeric.py +0 -0
  38. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/py.typed +0 -0
  39. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/snapshot.py +0 -0
  40. {assertpy2-2.0.1 → assertpy2-2.1.1}/assertpy2/string.py +0 -0
  41. {assertpy2-2.0.1 → assertpy2-2.1.1}/docs/logo-dark.svg +0 -0
  42. {assertpy2-2.0.1 → assertpy2-2.1.1}/docs/logo.svg +0 -0
  43. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_bool.py +0 -0
  44. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_class.py +0 -0
  45. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_collection.py +0 -0
  46. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_core.py +0 -0
  47. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_custom_dict.py +0 -0
  48. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_custom_list.py +0 -0
  49. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_datetime.py +0 -0
  50. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_description.py +0 -0
  51. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_dict.py +0 -0
  52. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_dict_compare.py +0 -0
  53. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_dyn.py +0 -0
  54. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_equals.py +0 -0
  55. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_expected_exception.py +0 -0
  56. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_extensions.py +0 -0
  57. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_extracting.py +0 -0
  58. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_fail.py +0 -0
  59. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_file.py +0 -0
  60. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_in.py +0 -0
  61. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_list.py +0 -0
  62. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_namedtuple.py +0 -0
  63. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_none.py +0 -0
  64. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_numbers.py +0 -0
  65. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_readme.py +0 -0
  66. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_same_as.py +0 -0
  67. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_snapshots.py +0 -0
  68. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_soft.py +0 -0
  69. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_soft_fail.py +0 -0
  70. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_string.py +0 -0
  71. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_type.py +0 -0
  72. {assertpy2-2.0.1 → assertpy2-2.1.1}/tests/test_warn.py +0 -0
@@ -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.1
3
+ Version: 2.1.1
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
@@ -38,7 +38,7 @@ Description-Content-Type: text/markdown
38
38
  </p>
39
39
 
40
40
  <p align="center">
41
- <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>
42
42
  Maintained fork of <a href="https://github.com/assertpy/assertpy">assertpy</a>.
43
43
  </p>
44
44
 
@@ -71,14 +71,41 @@ def test_user():
71
71
  assert_that(user).has_name("Alice")
72
72
  ```
73
73
 
74
- When an assertion fails, the error message tells you exactly what went wrong:
74
+ Composable matchers and structural matching:
75
75
 
76
76
  ```py
77
- assert_that(user["age"]).is_between(50, 120)
78
- # 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:
79
103
 
80
- assert_that(user["roles"]).contains("admin")
81
- # 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}
82
109
  ```
83
110
 
84
111
 
@@ -105,9 +132,13 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
105
132
 
106
133
  ## Features
107
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.
108
140
  - **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
109
- - **IDE support**: full autocomplete and chaining inference out of the box.
110
- - **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()`.
111
142
  - **Fluent chaining**: write assertions as readable one-liners that chain naturally.
112
143
  - **Dynamic assertions**: `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
113
144
  - **Dict comparison**: `is_equal_to()` with `ignore` and `include` for selective key matching.
@@ -118,6 +149,69 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
118
149
  - Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
119
150
 
120
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
+
121
215
  ## Soft assertions
122
216
 
123
217
  Collect all failures instead of stopping at the first one:
@@ -143,8 +237,45 @@ AssertionError: soft assertion failures:
143
237
 
144
238
  Use `soft_fail("message")` inside the block for non-halting explicit failures (unlike `fail()`, which stops immediately).
145
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.
146
265
 
147
- ## 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
148
279
 
149
280
  ### Dict comparison with ignore/include
150
281
 
@@ -173,12 +304,37 @@ assert_that(some_func).raises(RuntimeError).when_called_with("bad_arg")\
173
304
  .is_length(8).starts_with("some").is_equal_to("some err")
174
305
  ```
175
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
+
176
317
  ### Snapshot testing
177
318
 
178
319
  ```py
179
320
  assert_that({"a": 1, "b": 2, "c": 3}).snapshot()
180
321
  ```
181
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
+
182
338
  See the [full API reference](docs/api.md) for all assertion methods, examples, and advanced features.
183
339
 
184
340
 
@@ -200,9 +356,14 @@ from assertpy2 import assert_that, soft_assertions
200
356
  |---|---|---|
201
357
  | Maintained | Last release 2020 | Active |
202
358
  | Python | 2.7+ | 3.10-3.15 |
203
- | Type safety | No annotations | `Self` return types, `py.typed` (PEP 561) |
204
- | IDE support | No type info | Full autocomplete and chaining inference |
205
- | 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 |
206
367
  | Security | [CVE in snapshots](https://github.com/assertpy/assertpy/issues/156) | Fixed |
207
368
  | Open bugs | 15+ unresolved | All resolved |
208
369
 
@@ -7,7 +7,7 @@
7
7
  </p>
8
8
 
9
9
  <p align="center">
10
- <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>
11
11
  Maintained fork of <a href="https://github.com/assertpy/assertpy">assertpy</a>.
12
12
  </p>
13
13
 
@@ -40,14 +40,41 @@ def test_user():
40
40
  assert_that(user).has_name("Alice")
41
41
  ```
42
42
 
43
- When an assertion fails, the error message tells you exactly what went wrong:
43
+ Composable matchers and structural matching:
44
44
 
45
45
  ```py
46
- assert_that(user["age"]).is_between(50, 120)
47
- # 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:
48
72
 
49
- assert_that(user["roles"]).contains("admin")
50
- # 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}
51
78
  ```
52
79
 
53
80
 
@@ -74,9 +101,13 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
74
101
 
75
102
  ## Features
76
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.
77
109
  - **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
78
- - **IDE support**: full autocomplete and chaining inference out of the box.
79
- - **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()`.
80
111
  - **Fluent chaining**: write assertions as readable one-liners that chain naturally.
81
112
  - **Dynamic assertions**: `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
82
113
  - **Dict comparison**: `is_equal_to()` with `ignore` and `include` for selective key matching.
@@ -87,6 +118,69 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
87
118
  - Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
88
119
 
89
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
+
90
184
  ## Soft assertions
91
185
 
92
186
  Collect all failures instead of stopping at the first one:
@@ -112,8 +206,45 @@ AssertionError: soft assertion failures:
112
206
 
113
207
  Use `soft_fail("message")` inside the block for non-halting explicit failures (unlike `fail()`, which stops immediately).
114
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.
115
234
 
116
- ## 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
117
248
 
118
249
  ### Dict comparison with ignore/include
119
250
 
@@ -142,12 +273,37 @@ assert_that(some_func).raises(RuntimeError).when_called_with("bad_arg")\
142
273
  .is_length(8).starts_with("some").is_equal_to("some err")
143
274
  ```
144
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
+
145
286
  ### Snapshot testing
146
287
 
147
288
  ```py
148
289
  assert_that({"a": 1, "b": 2, "c": 3}).snapshot()
149
290
  ```
150
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
+
151
307
  See the [full API reference](docs/api.md) for all assertion methods, examples, and advanced features.
152
308
 
153
309
 
@@ -169,9 +325,14 @@ from assertpy2 import assert_that, soft_assertions
169
325
  |---|---|---|
170
326
  | Maintained | Last release 2020 | Active |
171
327
  | Python | 2.7+ | 3.10-3.15 |
172
- | Type safety | No annotations | `Self` return types, `py.typed` (PEP 561) |
173
- | IDE support | No type info | Full autocomplete and chaining inference |
174
- | 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 |
175
336
  | Security | [CVE in snapshots](https://github.com/assertpy/assertpy/issues/156) | Fixed |
176
337
  | Open bugs | 15+ unresolved | All resolved |
177
338
 
@@ -9,9 +9,17 @@ from .assertpy import (
9
9
  soft_assertions,
10
10
  soft_fail,
11
11
  )
12
+ from .async_assertions import AsyncAssertionBuilder
13
+ from .errors import AssertionFailure, DiffEntry, DiffResult
12
14
  from .file import contents_of
15
+ from .matchers import Matcher, match
13
16
 
14
17
  __all__ = [
18
+ "AssertionFailure",
19
+ "AsyncAssertionBuilder",
20
+ "DiffEntry",
21
+ "DiffResult",
22
+ "Matcher",
15
23
  "WarningLoggingAdapter",
16
24
  "__version__",
17
25
  "add_extension",
@@ -19,6 +27,7 @@ __all__ = [
19
27
  "assert_warn",
20
28
  "contents_of",
21
29
  "fail",
30
+ "match",
22
31
  "remove_extension",
23
32
  "soft_assertions",
24
33
  "soft_fail",