assertpy2 2.6.0__tar.gz → 2.8.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 (112) hide show
  1. {assertpy2-2.6.0 → assertpy2-2.8.0}/.gitignore +1 -0
  2. {assertpy2-2.6.0 → assertpy2-2.8.0}/PKG-INFO +38 -13
  3. {assertpy2-2.6.0 → assertpy2-2.8.0}/README.md +37 -12
  4. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/_mixin_base.py +2 -1
  5. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/_typing.py +13 -1
  6. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/assertpy.py +3 -2
  7. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/base.py +24 -5
  8. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/exception.py +29 -1
  9. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/matchers.py +55 -9
  10. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/pytest_plugin.py +5 -0
  11. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/warning.py +6 -3
  12. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/comparison.md +36 -1
  13. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/errors.md +29 -0
  14. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/getting-started.md +15 -0
  15. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/index.md +10 -1
  16. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/matchers.md +19 -0
  17. {assertpy2-2.6.0 → assertpy2-2.8.0}/pyproject.toml +2 -1
  18. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_expected_exception.py +14 -0
  19. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_expected_warning.py +26 -0
  20. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_matchers.py +21 -1
  21. assertpy2-2.8.0/tests/test_property_based.py +333 -0
  22. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_recursive_compare_attrs.py +28 -0
  23. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_recursive_compare_pydantic.py +28 -0
  24. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_rich_diff.py +30 -0
  25. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_structural.py +69 -1
  26. {assertpy2-2.6.0 → assertpy2-2.8.0}/uv.lock +25 -1
  27. {assertpy2-2.6.0 → assertpy2-2.8.0}/.codecov.yml +0 -0
  28. {assertpy2-2.6.0 → assertpy2-2.8.0}/.gitattributes +0 -0
  29. {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/dependabot.yml +0 -0
  30. {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/ci.yml +0 -0
  31. {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/codeql.yml +0 -0
  32. {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/docs.yml +0 -0
  33. {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/publish.yml +0 -0
  34. {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/scorecard.yml +0 -0
  35. {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/zizmor.yml +0 -0
  36. {assertpy2-2.6.0 → assertpy2-2.8.0}/CONTRIBUTING.md +0 -0
  37. {assertpy2-2.6.0 → assertpy2-2.8.0}/LICENSE +0 -0
  38. {assertpy2-2.6.0 → assertpy2-2.8.0}/SECURITY.md +0 -0
  39. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/__init__.py +0 -0
  40. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/_compat.py +0 -0
  41. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/async_assertions.py +0 -0
  42. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/behave_matchers.py +0 -0
  43. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/bytes_mixin.py +0 -0
  44. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/collection.py +0 -0
  45. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/contains.py +0 -0
  46. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/date.py +0 -0
  47. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/dict.py +0 -0
  48. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/dynamic.py +0 -0
  49. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/errors.py +0 -0
  50. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/extracting.py +0 -0
  51. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/file.py +0 -0
  52. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/helpers.py +0 -0
  53. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/json_mixin.py +0 -0
  54. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/numeric.py +0 -0
  55. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/py.typed +0 -0
  56. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/snapshot.py +0 -0
  57. {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/string.py +0 -0
  58. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/assertions.md +0 -0
  59. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/data.md +0 -0
  60. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/extending.md +0 -0
  61. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/fluent.md +0 -0
  62. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/integrations.md +0 -0
  63. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/logo-dark.svg +0 -0
  64. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/logo.svg +0 -0
  65. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/migration.md +0 -0
  66. {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/testing.md +0 -0
  67. {assertpy2-2.6.0 → assertpy2-2.8.0}/mkdocs.yml +0 -0
  68. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_async.py +0 -0
  69. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_behave_matchers.py +0 -0
  70. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_bool.py +0 -0
  71. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_bytes.py +0 -0
  72. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_class.py +0 -0
  73. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_collection.py +0 -0
  74. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_core.py +0 -0
  75. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_custom_dict.py +0 -0
  76. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_custom_list.py +0 -0
  77. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_datetime.py +0 -0
  78. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_description.py +0 -0
  79. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_dict.py +0 -0
  80. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_dict_compare.py +0 -0
  81. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_dyn.py +0 -0
  82. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_equals.py +0 -0
  83. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_errors.py +0 -0
  84. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_extensions.py +0 -0
  85. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_extracting.py +0 -0
  86. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_fail.py +0 -0
  87. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_file.py +0 -0
  88. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_grouped_soft.py +0 -0
  89. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_in.py +0 -0
  90. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_json.py +0 -0
  91. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_list.py +0 -0
  92. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_matcher_registry.py +0 -0
  93. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_matchers_phase3.py +0 -0
  94. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_namedtuple.py +0 -0
  95. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_none.py +0 -0
  96. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_not.py +0 -0
  97. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_numbers.py +0 -0
  98. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_overloads.py +0 -0
  99. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_phase2.py +0 -0
  100. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_pipeline.py +0 -0
  101. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_pytest_plugin.py +0 -0
  102. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_readme.py +0 -0
  103. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_recursive_compare.py +0 -0
  104. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_regex_groups.py +0 -0
  105. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_same_as.py +0 -0
  106. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_snapshots.py +0 -0
  107. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_soft.py +0 -0
  108. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_soft_fail.py +0 -0
  109. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_string.py +0 -0
  110. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_traceback.py +0 -0
  111. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_type.py +0 -0
  112. {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_warn.py +0 -0
@@ -10,6 +10,7 @@ __pycache__
10
10
  cover
11
11
  .coverage
12
12
  htmlcov
13
+ .hypothesis
13
14
  __snapshots
14
15
  mycustompath
15
16
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.6.0
3
+ Version: 2.8.0
4
4
  Summary: Fluent assertion library for Python with composable matchers, structural matching, and full type safety
5
5
  Project-URL: Homepage, https://github.com/Solganis/assertpy2
6
6
  Project-URL: Repository, https://github.com/Solganis/assertpy2
@@ -86,24 +86,49 @@ def test_user():
86
86
 
87
87
  ## Why fluent assertions?
88
88
 
89
- ```py
90
- # bare assert - passes, but failure message is useless
91
- assert user["age"] >= 18
92
- # AssertionError
93
-
94
- # assertpy2 - same check, clear failure message
95
- assert_that(user["age"]).is_greater_than_or_equal_to(18)
96
- # AssertionError: Expected <16> to be greater than or equal to <18>, but was not.
89
+ A fluent chain reads as one intent and replaces several bare asserts - and your IDE
90
+ offers only the methods that fit the value's type:
97
91
 
98
- # bare assert - three separate statements
92
+ ```py
93
+ # bare - three statements, no autocomplete help
99
94
  assert isinstance(items, list)
100
95
  assert len(items) == 3
101
96
  assert "admin" in items
102
97
 
103
- # assertpy2 - one fluent chain
98
+ # assertpy2 - one chain, type-aware autocomplete
104
99
  assert_that(items).is_type_of(list).is_length(3).contains("admin")
105
100
  ```
106
101
 
102
+ The real difference shows up when a test fails. Here a nested response has two wrong
103
+ fields. Plain `assert` dumps both structures and leaves you to find them:
104
+
105
+ ```text
106
+ assert response == expected
107
+ E AssertionError: assert {'id': 1, ...} == {'id': 1, ...}
108
+ E Omitting 1 identical items, use -vv to show
109
+ E Differing items:
110
+ E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Alice', 'role': 'admin'}}
111
+ E {'status': 'active'} != {'status': 'disabled'}
112
+ ```
113
+
114
+ assertpy2 reports the exact path to every difference:
115
+
116
+ ```text
117
+ assert_that(response).is_equal_to(expected)
118
+ --- Structured Diff ---
119
+ diff (dict):
120
+ status:
121
+ - 'active'
122
+ + 'disabled'
123
+ user.role:
124
+ - 'superadmin'
125
+ + 'admin'
126
+ ```
127
+
128
+ Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
129
+ For responses with dynamic fields (IDs, timestamps), validate a subset with
130
+ `matches_structure()` instead of exact equality.
131
+
107
132
  ## Type-aware autocomplete
108
133
 
109
134
  `assert_that()` uses `@overload` to return type-specific Protocols.
@@ -124,7 +149,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
124
149
  **Fluent API**
125
150
 
126
151
  - [**Composable matchers**](https://solganis.github.io/assertpy2/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
127
- - [**Structural matching**](https://solganis.github.io/assertpy2/matchers/#structural-matching): `matches_structure()` for declarative dict/API response validation.
152
+ - [**Structural matching**](https://solganis.github.io/assertpy2/matchers/#structural-matching): `matches_structure()` for declarative dict/API response validation, reporting the exact path to each mismatch on failure.
128
153
  - [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
129
154
  - [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
130
155
  - [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
@@ -143,7 +168,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
143
168
  - [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`. Group errors with `sa.group()`, or use `assert_all()`.
144
169
  - [**Async assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` with polling/retry for eventual consistency.
145
170
  - [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
146
- - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models. Circular reference protection.
171
+ - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
147
172
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
148
173
 
149
174
  **Type safety**
@@ -48,24 +48,49 @@ def test_user():
48
48
 
49
49
  ## Why fluent assertions?
50
50
 
51
- ```py
52
- # bare assert - passes, but failure message is useless
53
- assert user["age"] >= 18
54
- # AssertionError
55
-
56
- # assertpy2 - same check, clear failure message
57
- assert_that(user["age"]).is_greater_than_or_equal_to(18)
58
- # AssertionError: Expected <16> to be greater than or equal to <18>, but was not.
51
+ A fluent chain reads as one intent and replaces several bare asserts - and your IDE
52
+ offers only the methods that fit the value's type:
59
53
 
60
- # bare assert - three separate statements
54
+ ```py
55
+ # bare - three statements, no autocomplete help
61
56
  assert isinstance(items, list)
62
57
  assert len(items) == 3
63
58
  assert "admin" in items
64
59
 
65
- # assertpy2 - one fluent chain
60
+ # assertpy2 - one chain, type-aware autocomplete
66
61
  assert_that(items).is_type_of(list).is_length(3).contains("admin")
67
62
  ```
68
63
 
64
+ The real difference shows up when a test fails. Here a nested response has two wrong
65
+ fields. Plain `assert` dumps both structures and leaves you to find them:
66
+
67
+ ```text
68
+ assert response == expected
69
+ E AssertionError: assert {'id': 1, ...} == {'id': 1, ...}
70
+ E Omitting 1 identical items, use -vv to show
71
+ E Differing items:
72
+ E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Alice', 'role': 'admin'}}
73
+ E {'status': 'active'} != {'status': 'disabled'}
74
+ ```
75
+
76
+ assertpy2 reports the exact path to every difference:
77
+
78
+ ```text
79
+ assert_that(response).is_equal_to(expected)
80
+ --- Structured Diff ---
81
+ diff (dict):
82
+ status:
83
+ - 'active'
84
+ + 'disabled'
85
+ user.role:
86
+ - 'superadmin'
87
+ + 'admin'
88
+ ```
89
+
90
+ Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
91
+ For responses with dynamic fields (IDs, timestamps), validate a subset with
92
+ `matches_structure()` instead of exact equality.
93
+
69
94
  ## Type-aware autocomplete
70
95
 
71
96
  `assert_that()` uses `@overload` to return type-specific Protocols.
@@ -86,7 +111,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
86
111
  **Fluent API**
87
112
 
88
113
  - [**Composable matchers**](https://solganis.github.io/assertpy2/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
89
- - [**Structural matching**](https://solganis.github.io/assertpy2/matchers/#structural-matching): `matches_structure()` for declarative dict/API response validation.
114
+ - [**Structural matching**](https://solganis.github.io/assertpy2/matchers/#structural-matching): `matches_structure()` for declarative dict/API response validation, reporting the exact path to each mismatch on failure.
90
115
  - [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
91
116
  - [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
92
117
  - [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
@@ -105,7 +130,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
105
130
  - [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`. Group errors with `sa.group()`, or use `assert_all()`.
106
131
  - [**Async assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` with polling/retry for eventual consistency.
107
132
  - [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
108
- - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models. Circular reference protection.
133
+ - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
109
134
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
110
135
 
111
136
  **Type safety**
@@ -17,6 +17,7 @@ class _MixinBase:
17
17
  logger: logging.LoggerAdapter
18
18
  _not_expected: bool
19
19
  _expected_warning: type[Warning] | None
20
+ _return_value: object
20
21
 
21
22
  def error(
22
23
  self,
@@ -32,7 +33,7 @@ class _MixinBase:
32
33
  val: object,
33
34
  description: str = ...,
34
35
  kind: str | None = ...,
35
- expected: BaseException | None = ...,
36
+ expected: type[BaseException] | None = ...,
36
37
  logger: logging.LoggerAdapter | None = ...,
37
38
  ) -> Self: ...
38
39
 
@@ -215,6 +215,17 @@ if TYPE_CHECKING:
215
215
  def is_hex_equal_to(self, expected_hex: str) -> Self: ...
216
216
  def decoded_as(self, encoding: str = ...) -> _StringAssertion: ...
217
217
 
218
+ class _InvokedAssertion(_StringAssertion, Protocol):
219
+ """Assertions available after ``when_called_with()`` captured an exception/warning message.
220
+
221
+ The captured message is a ``str`` (hence the string assertions); :meth:`returned` pivots to
222
+ the value the callable returned. Its static type is unknown, so ``returned()`` exposes the
223
+ type-agnostic core assertions (``is_equal_to``, ``is_instance_of``, ``satisfies``, ...) -
224
+ type-safe by construction, never advertising methods that may not apply.
225
+ """
226
+
227
+ def returned(self) -> _CoreAssertion: ...
228
+
218
229
  class _CallableAssertion(_CoreAssertion, Protocol):
219
230
  """Assertions available for callable values."""
220
231
 
@@ -222,5 +233,6 @@ if TYPE_CHECKING:
222
233
  def does_not_raise(self, ex: type) -> Self: ...
223
234
  def warns(self, warning: type[Warning] = ...) -> Self: ...
224
235
  def does_not_warn(self, warning: type[Warning] = ...) -> Self: ...
225
- def when_called_with(self, *some_args: object, **some_kwargs: object) -> Self: ...
236
+ # captured value (exception or warning message) is a str; returned() pivots to the call result
237
+ def when_called_with(self, *some_args: object, **some_kwargs: object) -> _InvokedAssertion: ...
226
238
  def eventually(self, *, timeout: float = ..., interval: float = ...) -> AsyncAssertionBuilder: ...
@@ -37,7 +37,7 @@ from .date import DateMixin
37
37
  from .dict import DictMixin
38
38
  from .dynamic import DynamicMixin
39
39
  from .errors import AssertionFailure
40
- from .exception import ExceptionMixin
40
+ from .exception import _UNSET, ExceptionMixin
41
41
  from .extracting import ExtractingMixin
42
42
  from .file import FileMixin
43
43
  from .helpers import HelpersMixin
@@ -47,7 +47,7 @@ from .snapshot import SnapshotMixin
47
47
  from .string import StringMixin
48
48
  from .warning import WarningMixin
49
49
 
50
- __version__ = "2.6.0"
50
+ __version__ = "2.8.0"
51
51
 
52
52
  __tracebackhide__ = True # clean tracebacks via py.test integration
53
53
  contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
@@ -589,6 +589,7 @@ class AssertionBuilder(
589
589
  self.logger = logger if logger else _default_logger
590
590
  self._not_expected = False
591
591
  self._expected_warning = None
592
+ self._return_value = _UNSET
592
593
 
593
594
  @property
594
595
  def not_(self) -> NegatedBuilder:
@@ -430,7 +430,13 @@ class BaseMixin(_MixinBase):
430
430
  """
431
431
  if isinstance(matcher, Matcher):
432
432
  if not matcher.matches(self.val):
433
- return self.error(f"Expected {matcher.describe()}, but {matcher.describe_mismatch(self.val)}.")
433
+ description = matcher.describe()
434
+ return self.error(
435
+ f"Expected {description}, but {matcher.describe_mismatch(self.val)}.",
436
+ actual=self.val,
437
+ expected=description,
438
+ diff=DiffResult(kind="match", entries=[DiffEntry(path=".", actual=self.val, expected=description)]),
439
+ )
434
440
  elif callable(matcher):
435
441
  if not matcher(self.val):
436
442
  return self.error(f"Expected <{self.val}> to satisfy <{matcher}>, but did not.")
@@ -466,11 +472,17 @@ class BaseMixin(_MixinBase):
466
472
  if not isinstance(self.val, collections.abc.Iterable):
467
473
  raise TypeError("val is not iterable")
468
474
  if isinstance(matcher, Matcher):
475
+ description = matcher.describe()
469
476
  for i, item in enumerate(self.val):
470
477
  if not matcher.matches(item):
471
478
  return self.error(
472
- f"Expected all items to satisfy {matcher.describe()}, but item at index {i} <{item}> did not:"
473
- f" {matcher.describe_mismatch(item)}."
479
+ f"Expected all items to satisfy {description}, but item at index {i} <{item}> did not:"
480
+ f" {matcher.describe_mismatch(item)}.",
481
+ actual=item,
482
+ expected=description,
483
+ diff=DiffResult(
484
+ kind="match", entries=[DiffEntry(path=f"[{i}]", actual=item, expected=description)]
485
+ ),
474
486
  )
475
487
  elif callable(matcher):
476
488
  for i, item in enumerate(self.val):
@@ -515,10 +527,17 @@ class BaseMixin(_MixinBase):
515
527
  if not isinstance(spec, dict):
516
528
  raise TypeError("given arg must be a dict")
517
529
  matcher = StructureMatcher(spec)
518
- if not matcher.matches(self.val):
530
+ mismatches = matcher.collect_mismatches(self.val)
531
+ if mismatches:
532
+ entries = [
533
+ DiffEntry(path=path, actual=actual, expected=description) for path, actual, description in mismatches
534
+ ]
519
535
  return self.error(
520
536
  f"Expected <{self.val}> to match structure {matcher.describe()}, but"
521
- f" {matcher.describe_mismatch(self.val)}."
537
+ f" {matcher.describe_mismatch(self.val)}.",
538
+ actual=self.val,
539
+ expected=spec,
540
+ diff=DiffResult(kind="match", entries=entries),
522
541
  )
523
542
  return self
524
543
 
@@ -9,6 +9,8 @@ if TYPE_CHECKING:
9
9
 
10
10
  __tracebackhide__ = True
11
11
 
12
+ _UNSET = object() # sentinel: no return value captured yet
13
+
12
14
 
13
15
  class _InertBuilder:
14
16
  """No-op builder returned after a failed raises/when_called_with in soft mode.
@@ -105,10 +107,34 @@ class ExceptionMixin(_MixinBase):
105
107
  )
106
108
  return cast("Self", _InertBuilder())
107
109
 
110
+ def returned(self) -> Self:
111
+ """Pivots the chain to the value ``val()`` returned during :meth:`when_called_with`.
112
+
113
+ Use after a call that completed normally (:meth:`~assertpy2.warning.WarningMixin.warns`,
114
+ :meth:`~assertpy2.warning.WarningMixin.does_not_warn`, or :meth:`does_not_raise`) to assert
115
+ on the return value in the same chain.
116
+
117
+ Examples:
118
+ Usage::
119
+
120
+ assert_that(make_client).warns(DeprecationWarning).when_called_with().returned().is_instance_of(Client)
121
+ assert_that(adder).does_not_raise(TypeError).when_called_with(1, 2).returned().is_equal_to(3)
122
+
123
+ Returns:
124
+ AssertionBuilder: a new instance wrapping the captured return value
125
+
126
+ Raises:
127
+ TypeError: if no return value was captured (the call raised, or :meth:`when_called_with`
128
+ was not invoked first)
129
+ """
130
+ if self._return_value is _UNSET:
131
+ raise TypeError("no return value captured; returned() is only valid after a call that completed normally")
132
+ return self.builder(self._return_value, self.description, self.kind, logger=self.logger)
133
+
108
134
  def _when_called_with_not_expected(self, *some_args, **some_kwargs) -> Self:
109
135
  assert self.expected is not None
110
136
  try:
111
- self.val(*some_args, **some_kwargs)
137
+ result = self.val(*some_args, **some_kwargs)
112
138
  except BaseException as e:
113
139
  if issubclass(type(e), self.expected):
114
140
  self.error(
@@ -117,6 +143,8 @@ class ExceptionMixin(_MixinBase):
117
143
  f" but did raise <{type(e).__name__}>."
118
144
  )
119
145
  return cast("Self", _InertBuilder())
146
+ return self
147
+ self._return_value = result
120
148
  return self
121
149
 
122
150
  def does_not_raise(self, ex) -> Self:
@@ -512,6 +512,26 @@ class EachMatcher(BaseMatcher):
512
512
  return f"was <{value}>"
513
513
 
514
514
 
515
+ class _MissingSentinel:
516
+ """Placeholder recorded for a spec key absent from the value during structural matching."""
517
+
518
+ def __repr__(self) -> str:
519
+ return "<missing>"
520
+
521
+
522
+ _MISSING = _MissingSentinel()
523
+
524
+
525
+ def _describe_spec_value(value: object) -> str:
526
+ """Describe a single structure-spec value (matcher, nested dict spec, or raw value)."""
527
+ if isinstance(value, Matcher):
528
+ return value.describe()
529
+ if isinstance(value, dict):
530
+ parts = [f"{key}: {_describe_spec_value(sub_value)}" for key, sub_value in value.items()]
531
+ return f"{{{', '.join(parts)}}}"
532
+ return f"<{value}>"
533
+
534
+
515
535
  class StructureMatcher(BaseMatcher):
516
536
  """Matches dicts against a structure spec where values are matchers, raw values, or nested dicts."""
517
537
 
@@ -559,16 +579,42 @@ class StructureMatcher(BaseMatcher):
559
579
  return f"at <{current_path}>: expected <{expected}>, but was <{actual}>"
560
580
  return None
561
581
 
582
+ def collect_mismatches(self, value: dict) -> list[tuple[str, object, str]]:
583
+ """Collect every structural mismatch as ``(path, actual, expected_description)``.
584
+
585
+ Unlike :meth:`describe_mismatch`, this does not stop at the first failure and joins nested
586
+ paths, so callers can build a path-level :class:`~assertpy2.errors.DiffResult`.
587
+ """
588
+ return self._collect(value, self._spec, "", set())
589
+
590
+ def _collect(self, value: dict, spec: dict, path: str, seen: set[tuple[int, int]]) -> list[tuple[str, object, str]]:
591
+ pair_id = (id(value), id(spec))
592
+ if pair_id in seen:
593
+ return [(path or "root", "<circular ref>", "<circular ref>")]
594
+ seen = seen | {pair_id}
595
+ mismatches: list[tuple[str, object, str]] = []
596
+ for key, expected in spec.items():
597
+ current_path = f"{path}.{key}" if path else str(key)
598
+ if key not in value:
599
+ mismatches.append((current_path, _MISSING, _describe_spec_value(expected)))
600
+ continue
601
+ actual = value[key]
602
+ if isinstance(expected, StructureMatcher) and isinstance(actual, dict):
603
+ mismatches.extend(self._collect(actual, expected._spec, current_path, seen))
604
+ elif isinstance(expected, Matcher):
605
+ if not expected.matches(actual):
606
+ mismatches.append((current_path, actual, expected.describe()))
607
+ elif isinstance(expected, dict):
608
+ if isinstance(actual, dict):
609
+ mismatches.extend(self._collect(actual, expected, current_path, seen))
610
+ else:
611
+ mismatches.append((current_path, actual, "a dict"))
612
+ elif actual != expected:
613
+ mismatches.append((current_path, actual, f"<{expected}>"))
614
+ return mismatches
615
+
562
616
  def _describe_spec(self, spec: dict) -> str:
563
- parts = []
564
- for key, value in spec.items():
565
- if isinstance(value, Matcher):
566
- parts.append(f"{key}: {value.describe()}")
567
- elif isinstance(value, dict):
568
- parts.append(f"{key}: {self._describe_spec(value)}")
569
- else:
570
- parts.append(f"{key}: <{value}>")
571
- return f"{{{', '.join(parts)}}}"
617
+ return _describe_spec_value(spec)
572
618
 
573
619
 
574
620
  # --- Custom matcher registry ---
@@ -121,6 +121,11 @@ def _format_diff(diff, *, color: bool = False, max_entries: int = 50) -> str:
121
121
  lines.append(f" {path}:")
122
122
  lines.append(f" {red}- {entry.actual!r}{reset}")
123
123
  lines.append(f" {green}+ {entry.expected!r}{reset}")
124
+ elif kind == "match":
125
+ for entry in visible:
126
+ lines.append(
127
+ f" {cyan}{entry.path}{reset}: expected {entry.expected}, but was {red}{entry.actual!r}{reset}"
128
+ )
124
129
  elif kind in {"set", "contains"}:
125
130
  extra = [e for e in visible if e.path == "extra"]
126
131
  missing = [e for e in visible if e.path == "missing"]
@@ -89,10 +89,12 @@ class WarningMixin(_MixinBase):
89
89
  def _when_called_with_warning(self, expected: type[Warning], *some_args, **some_kwargs) -> Self:
90
90
  with warnings.catch_warnings(record=True) as caught:
91
91
  warnings.simplefilter("always") # bypass __warningregistry__ "show once" dedup and filterwarnings=error
92
- self.val(*some_args, **some_kwargs)
92
+ result = self.val(*some_args, **some_kwargs)
93
93
  matched = [w for w in caught if issubclass(w.category, expected)]
94
94
  if matched:
95
- return self.builder(str(matched[0].message), self.description, self.kind, logger=self.logger)
95
+ captured = self.builder(str(matched[0].message), self.description, self.kind, logger=self.logger)
96
+ captured._return_value = result
97
+ return captured
96
98
  if caught:
97
99
  seen = ", ".join(sorted({w.category.__name__ for w in caught}))
98
100
  self.error(
@@ -110,7 +112,7 @@ class WarningMixin(_MixinBase):
110
112
  def _when_called_with_not_warning(self, expected: type[Warning], *some_args, **some_kwargs) -> Self:
111
113
  with warnings.catch_warnings(record=True) as caught:
112
114
  warnings.simplefilter("always") # bypass __warningregistry__ "show once" dedup and filterwarnings=error
113
- self.val(*some_args, **some_kwargs)
115
+ result = self.val(*some_args, **some_kwargs)
114
116
  matched = [w for w in caught if issubclass(w.category, expected)]
115
117
  if matched:
116
118
  seen = ", ".join(sorted({w.category.__name__ for w in matched}))
@@ -120,4 +122,5 @@ class WarningMixin(_MixinBase):
120
122
  f" but did warn <{seen}>."
121
123
  )
122
124
  return cast("Self", _InertBuilder())
125
+ self._return_value = result
123
126
  return self
@@ -82,6 +82,41 @@ The same check - `id` is a positive integer and `name` is a non-empty string - i
82
82
 
83
83
  Only assertpy2 offers both the typed structural form and the `==` form from a single import.
84
84
 
85
+ ## When it fails
86
+
87
+ A nested response, after `role` comes back as `"superadmin"`. What each library prints on failure:
88
+
89
+ === "assertpy2"
90
+
91
+ ```text
92
+ assert_that(response).matches_structure({...})
93
+ --- Structured Diff ---
94
+ diff (match):
95
+ user.role: expected a value in <('admin', 'user')>, but was 'superadmin'
96
+ ```
97
+
98
+ === "pytest assert"
99
+
100
+ ```text
101
+ assert response == expected
102
+ E Differing items:
103
+ E {'user': {'name': 'Alice', 'role': 'superadmin', 'age': 30}} !=
104
+ E {'user': {'name': 'Alice', 'role': 'admin', 'age': 30}}
105
+ ```
106
+
107
+ === "dirty-equals"
108
+
109
+ ```text
110
+ assert response == {"user": {"role": IsOneOf("admin", "user"), ...}}
111
+ E Differing items:
112
+ E {'user': {'name': 'Alice', 'role': 'superadmin', 'age': 30}} !=
113
+ E {'user': {'name': IsStr, 'role': IsOneOf('admin', 'user'), 'age': IsInt}}
114
+ ```
115
+
116
+ Only assertpy2 prints the path (`user.role`) and the exact predicate that failed. dirty-equals and the
117
+ assertpy2 `==` form both hand rendering to pytest, which dumps the whole differing container for you to
118
+ scan. The fluent form trades the zero-import convenience of `==` for a path-level diff.
119
+
85
120
  ## Style and typing
86
121
 
87
122
  | | pytest assert | PyHamcrest | assertpy | dirty-equals | **assertpy2** |
@@ -126,7 +161,7 @@ Only assertpy2 offers both the typed structural form and the `==` form from a si
126
161
 
127
162
  | | pytest assert | PyHamcrest | assertpy | dirty-equals | **assertpy2** |
128
163
  |---|:---:|:---:|:---:|:---:|:---:|
129
- | Latest release | built-in | 2.1.0 | 1.1 (2020) | 0.9.0 | **2.5.0** |
164
+ | Latest release | built-in | 2.1.0 | 1.1 (2020) | 0.9.0 | **2.8.0** |
130
165
  | Runtime dependencies | none | none | none | none | **none on 3.11+** |
131
166
  | License | MIT | BSD | BSD | MIT | BSD-3 |
132
167
 
@@ -25,6 +25,9 @@ except AssertionError as e:
25
25
  # DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
26
26
  ```
27
27
 
28
+ Matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`) attach a `DiffResult` with
29
+ `kind='match'`, where each entry's `expected` holds the failed predicate's description.
30
+
28
31
  When the pytest plugin is active (auto-registered via the `pytest11` entry point, no configuration
29
32
  needed), this data is rendered as extra report sections. See [Rich pytest diffs](#rich-pytest-diffs)
30
33
  for supported types and configuration.
@@ -45,6 +48,7 @@ rendered by the plugin as colored diff sections.
45
48
  | Pydantic model | `model` | Field-by-field via `model_dump()`, recursive into nested models |
46
49
  | other | `scalar` | Single actual-vs-expected entry |
47
50
  | `contains` family | `contains` | Missing and extra items |
51
+ | matcher mismatch | `match` | `matches_structure()` / `satisfies()` / `each()`: path + failed predicate |
48
52
 
49
53
  ```
50
54
  --- AssertionFailure ---
@@ -61,6 +65,20 @@ Nested structures are diffed recursively and report the exact path to the differ
61
65
  `[1].name`). Circular references are detected and shown as `<circular ref>` rather than recursing
62
66
  forever.
63
67
 
68
+ Matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`) emit a `match` diff that
69
+ shows the path and the predicate that failed, for every field, not just the first:
70
+
71
+ ```text
72
+ diff (match):
73
+ user.name: expected a non-empty string, but was ''
74
+ user.role: expected a value in <('admin', 'user')>, but was 'superadmin'
75
+ user.age: expected a value between <18> and <120>, but was 15
76
+ ```
77
+
78
+ The rich `match`/`dict` diff comes from the fluent form. The `==` drop-in for matchers (for example
79
+ `assert response == {"id": match.is_positive()}`) hands rendering to pytest instead, which prints its
80
+ own dict comparison without the path.
81
+
64
82
  ### Configuration
65
83
 
66
84
  ```toml
@@ -119,6 +137,17 @@ The category defaults to `Warning` (matches any warning) and matches subclasses.
119
137
  assert_that(safe_func).does_not_warn(DeprecationWarning).when_called_with("foo")
120
138
  ```
121
139
 
140
+ To also assert on the value the call returned (alongside the warning, or after `does_not_warn` /
141
+ `does_not_raise`), pivot with `returned()`:
142
+
143
+ ```python
144
+ assert_that(make_client).warns(DeprecationWarning).when_called_with().returned().is_instance_of(Client)
145
+ assert_that(adder).does_not_raise(TypeError).when_called_with(1, 2).returned().is_equal_to(3)
146
+ ```
147
+
148
+ `returned()` exposes the type-agnostic core assertions (`is_equal_to`, `is_instance_of`, `satisfies`,
149
+ ...); it raises `TypeError` if the call raised (there is no return value to inspect).
150
+
122
151
  !!! warning "Not thread-safe"
123
152
  `warns()` / `does_not_warn()` rely on `warnings.catch_warnings()`, which mutates process-global
124
153
  state. They are safe within a single thread (including multiple `asyncio` tasks on one event
@@ -35,6 +35,21 @@ assert_that(5).is_greater_than(10)
35
35
  # AssertionError: Expected <5> to be greater than <10>, but was not.
36
36
  ```
37
37
 
38
+ For dicts, dataclasses, and other structures, the pytest plugin renders a path-level diff that points
39
+ straight at the differing field instead of dumping the whole value:
40
+
41
+ ```text
42
+ assert_that(actual).is_equal_to(expected)
43
+ --- Structured Diff ---
44
+ diff (dict):
45
+ user.role:
46
+ - 'superadmin'
47
+ + 'admin'
48
+ ```
49
+
50
+ The same path-level diff backs `matches_structure()`, `satisfies()`, and `each()`. See
51
+ [Errors & Reporting](errors.md) for the full diff format and configuration.
52
+
38
53
  ## Collect multiple failures
39
54
 
40
55
  Use soft assertions to report every failure at once instead of stopping at the first:
@@ -12,7 +12,7 @@ rich structural diffs on failure.
12
12
  accurate autocomplete and type checkers catch misuse. This is the core advantage over `assertpy` and
13
13
  most alternatives.
14
14
  - **Composable matchers.** `match.greater_than(5)`, `match.is_uuid()`, combined with `&`, `|`, `~`.
15
- - **Structural matching.** Declarative validation of dicts and API responses.
15
+ - **Structural matching.** Declarative validation of dicts and API responses, with the exact path to each mismatch on failure.
16
16
  - **Soft and async assertions.** Collect multiple failures; poll for eventual consistency with `eventually()`.
17
17
  - **Structured failures.** `AssertionFailure` exposes `.actual`, `.expected`, and `.diff`; the pytest plugin
18
18
  renders recursive diffs for lists, dicts, dataclasses, namedtuples, and Pydantic models.
@@ -37,5 +37,14 @@ assert_that({"id": 1, "name": "Alice"}).matches_structure(
37
37
  )
38
38
  ```
39
39
 
40
+ When a check fails, the pytest plugin points at the exact field instead of dumping both structures:
41
+
42
+ ```text
43
+ assert_that(response).matches_structure({...})
44
+ --- Structured Diff ---
45
+ diff (match):
46
+ user.role: expected a value in <('admin', 'user')>, but was 'superadmin'
47
+ ```
48
+
40
49
  See [Getting Started](getting-started.md) to dive in, or browse [Type Assertions](assertions.md),
41
50
  [Matchers](matchers.md), and the rest of the navigation for the full set of assertions and integrations.