assertpy2 2.7.0__tar.gz → 2.8.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 (119) hide show
  1. {assertpy2-2.7.0 → assertpy2-2.8.1}/PKG-INFO +42 -16
  2. {assertpy2-2.7.0 → assertpy2-2.8.1}/README.md +41 -15
  3. assertpy2-2.8.1/assertpy2/_introspection.py +66 -0
  4. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/_mixin_base.py +2 -1
  5. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/assertpy.py +11 -5
  6. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/async_assertions.py +1 -1
  7. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/base.py +58 -46
  8. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/behave_matchers.py +14 -9
  9. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/collection.py +7 -7
  10. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/contains.py +11 -17
  11. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/date.py +2 -2
  12. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/dict.py +8 -10
  13. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/dynamic.py +6 -5
  14. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/errors.py +3 -4
  15. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/exception.py +3 -3
  16. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/extracting.py +4 -6
  17. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/file.py +2 -2
  18. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/helpers.py +12 -12
  19. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/matchers.py +75 -26
  20. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/pytest_plugin.py +17 -11
  21. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/snapshot.py +7 -4
  22. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/string.py +7 -5
  23. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/assertions.md +15 -1
  24. assertpy2-2.8.1/docs/assets/diff-equal.png +0 -0
  25. assertpy2-2.8.1/docs/assets/diff-equal.svg +64 -0
  26. assertpy2-2.8.1/docs/assets/diff-match.svg +51 -0
  27. assertpy2-2.8.1/docs/assets/diff-sequence.svg +52 -0
  28. assertpy2-2.8.1/docs/assets/diff-set.svg +48 -0
  29. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/comparison.md +62 -21
  30. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/errors.md +29 -8
  31. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/extending.md +9 -0
  32. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/getting-started.md +14 -1
  33. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/index.md +18 -4
  34. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/matchers.md +28 -0
  35. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/migration.md +1 -1
  36. assertpy2-2.8.1/docs/type-safety.md +61 -0
  37. {assertpy2-2.7.0 → assertpy2-2.8.1}/mkdocs.yml +1 -0
  38. {assertpy2-2.7.0 → assertpy2-2.8.1}/pyproject.toml +4 -4
  39. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_datetime.py +16 -8
  40. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_dict_compare.py +10 -5
  41. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_expected_exception.py +4 -2
  42. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_matchers.py +21 -1
  43. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_pytest_plugin.py +19 -1
  44. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_recursive_compare_attrs.py +28 -0
  45. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_recursive_compare_pydantic.py +28 -0
  46. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_rich_diff.py +30 -0
  47. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_structural.py +69 -1
  48. {assertpy2-2.7.0 → assertpy2-2.8.1}/uv.lock +1 -1
  49. {assertpy2-2.7.0 → assertpy2-2.8.1}/.codecov.yml +0 -0
  50. {assertpy2-2.7.0 → assertpy2-2.8.1}/.gitattributes +0 -0
  51. {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/dependabot.yml +0 -0
  52. {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/ci.yml +0 -0
  53. {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/codeql.yml +0 -0
  54. {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/docs.yml +0 -0
  55. {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/publish.yml +0 -0
  56. {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/scorecard.yml +0 -0
  57. {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/zizmor.yml +0 -0
  58. {assertpy2-2.7.0 → assertpy2-2.8.1}/.gitignore +0 -0
  59. {assertpy2-2.7.0 → assertpy2-2.8.1}/CONTRIBUTING.md +0 -0
  60. {assertpy2-2.7.0 → assertpy2-2.8.1}/LICENSE +0 -0
  61. {assertpy2-2.7.0 → assertpy2-2.8.1}/SECURITY.md +0 -0
  62. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/__init__.py +0 -0
  63. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/_compat.py +0 -0
  64. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/_typing.py +0 -0
  65. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/bytes_mixin.py +0 -0
  66. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/json_mixin.py +0 -0
  67. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/numeric.py +0 -0
  68. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/py.typed +0 -0
  69. {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/warning.py +0 -0
  70. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/data.md +0 -0
  71. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/fluent.md +0 -0
  72. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/integrations.md +0 -0
  73. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/logo-dark.svg +0 -0
  74. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/logo.svg +0 -0
  75. {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/testing.md +0 -0
  76. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_async.py +0 -0
  77. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_behave_matchers.py +0 -0
  78. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_bool.py +0 -0
  79. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_bytes.py +0 -0
  80. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_class.py +0 -0
  81. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_collection.py +0 -0
  82. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_core.py +0 -0
  83. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_custom_dict.py +0 -0
  84. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_custom_list.py +0 -0
  85. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_description.py +0 -0
  86. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_dict.py +0 -0
  87. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_dyn.py +0 -0
  88. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_equals.py +0 -0
  89. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_errors.py +0 -0
  90. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_expected_warning.py +0 -0
  91. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_extensions.py +0 -0
  92. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_extracting.py +0 -0
  93. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_fail.py +0 -0
  94. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_file.py +0 -0
  95. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_grouped_soft.py +0 -0
  96. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_in.py +0 -0
  97. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_json.py +0 -0
  98. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_list.py +0 -0
  99. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_matcher_registry.py +0 -0
  100. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_matchers_phase3.py +0 -0
  101. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_namedtuple.py +0 -0
  102. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_none.py +0 -0
  103. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_not.py +0 -0
  104. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_numbers.py +0 -0
  105. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_overloads.py +0 -0
  106. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_phase2.py +0 -0
  107. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_pipeline.py +0 -0
  108. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_property_based.py +0 -0
  109. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_readme.py +0 -0
  110. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_recursive_compare.py +0 -0
  111. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_regex_groups.py +0 -0
  112. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_same_as.py +0 -0
  113. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_snapshots.py +0 -0
  114. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_soft.py +0 -0
  115. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_soft_fail.py +0 -0
  116. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_string.py +0 -0
  117. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_traceback.py +0 -0
  118. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_type.py +0 -0
  119. {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_warn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.7.0
3
+ Version: 2.8.1
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
@@ -66,7 +66,7 @@ Description-Content-Type: text/markdown
66
66
 
67
67
  ---
68
68
 
69
- ## Quick start
69
+ ## [Quick start](https://solganis.github.io/assertpy2/getting-started/)
70
70
 
71
71
  ```bash
72
72
  pip install assertpy2 # drop-in replacement for assertpy, just change the import
@@ -84,27 +84,48 @@ def test_user():
84
84
  assert_that(user).has_name("Alice")
85
85
  ```
86
86
 
87
- ## Why fluent assertions?
87
+ Browse the [full documentation](https://solganis.github.io/assertpy2/) for every assertion, matcher, and integration.
88
88
 
89
- ```py
90
- # bare assert - passes, but failure message is useless
91
- assert user["age"] >= 18
92
- # AssertionError
89
+ ## [Why fluent assertions?](https://solganis.github.io/assertpy2/comparison/)
93
90
 
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.
91
+ A fluent chain reads as one intent and replaces several bare asserts - and your IDE
92
+ offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/type-safety/):
97
93
 
98
- # bare assert - three separate statements
94
+ ```py
95
+ # bare - three statements, no autocomplete help
99
96
  assert isinstance(items, list)
100
97
  assert len(items) == 3
101
98
  assert "admin" in items
102
99
 
103
- # assertpy2 - one fluent chain
100
+ # assertpy2 - one chain, type-aware autocomplete
104
101
  assert_that(items).is_type_of(list).is_length(3).contains("admin")
105
102
  ```
106
103
 
107
- ## Type-aware autocomplete
104
+ The real difference shows up when a test fails. Here a nested response has two wrong
105
+ fields. Plain `assert` dumps both structures and leaves you to find them:
106
+
107
+ ```text
108
+ assert response == expected
109
+ E AssertionError: assert {'id': 1, ...} == {'id': 1, ...}
110
+ E Omitting 1 identical items, use -vv to show
111
+ E Differing items:
112
+ E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Alice', 'role': 'admin'}}
113
+ E {'status': 'active'} != {'status': 'disabled'}
114
+ ```
115
+
116
+ assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs), in color:
117
+
118
+ ```python
119
+ assert_that(response).is_equal_to(expected)
120
+ ```
121
+
122
+ <img src="docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: status and user.role shown with their paths, removals in red and additions in green">
123
+
124
+ Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
125
+ For responses with dynamic fields (IDs, timestamps), validate a subset with
126
+ [`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
127
+
128
+ ## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
108
129
 
109
130
  `assert_that()` uses `@overload` to return type-specific Protocols.
110
131
  Your IDE shows only methods relevant to the value you're testing, not all 100+:
@@ -117,6 +138,8 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
117
138
 
118
139
  9 type-specific Protocols instead of one `Any`. Works in PyCharm, VS Code, and any LSP-compatible editor.
119
140
 
141
+ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) guide for the full walkthrough.
142
+
120
143
  ---
121
144
 
122
145
  ## Features
@@ -124,7 +147,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
124
147
  **Fluent API**
125
148
 
126
149
  - [**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.
150
+ - [**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
151
  - [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
129
152
  - [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
130
153
  - [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
@@ -143,8 +166,9 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
143
166
  - [**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
167
  - [**Async assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` with polling/retry for eventual consistency.
145
168
  - [**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.
169
+ - [**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
170
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
171
+ - **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
148
172
 
149
173
  **Type safety**
150
174
 
@@ -166,7 +190,7 @@ See the [full documentation](https://solganis.github.io/assertpy2/) for all asse
166
190
 
167
191
  ---
168
192
 
169
- ## Integrations
193
+ ## [Integrations](https://solganis.github.io/assertpy2/integrations/)
170
194
 
171
195
  ### Allure
172
196
 
@@ -214,6 +238,8 @@ def step_impl(context, age):
214
238
 
215
239
  Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.
216
240
 
241
+ See the [Integrations guide](https://solganis.github.io/assertpy2/integrations/) for attachment modes, configuration, and full examples.
242
+
217
243
  ---
218
244
 
219
245
  <p align="center">
@@ -28,7 +28,7 @@
28
28
 
29
29
  ---
30
30
 
31
- ## Quick start
31
+ ## [Quick start](https://solganis.github.io/assertpy2/getting-started/)
32
32
 
33
33
  ```bash
34
34
  pip install assertpy2 # drop-in replacement for assertpy, just change the import
@@ -46,27 +46,48 @@ def test_user():
46
46
  assert_that(user).has_name("Alice")
47
47
  ```
48
48
 
49
- ## Why fluent assertions?
49
+ Browse the [full documentation](https://solganis.github.io/assertpy2/) for every assertion, matcher, and integration.
50
50
 
51
- ```py
52
- # bare assert - passes, but failure message is useless
53
- assert user["age"] >= 18
54
- # AssertionError
51
+ ## [Why fluent assertions?](https://solganis.github.io/assertpy2/comparison/)
55
52
 
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.
53
+ A fluent chain reads as one intent and replaces several bare asserts - and your IDE
54
+ offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/type-safety/):
59
55
 
60
- # bare assert - three separate statements
56
+ ```py
57
+ # bare - three statements, no autocomplete help
61
58
  assert isinstance(items, list)
62
59
  assert len(items) == 3
63
60
  assert "admin" in items
64
61
 
65
- # assertpy2 - one fluent chain
62
+ # assertpy2 - one chain, type-aware autocomplete
66
63
  assert_that(items).is_type_of(list).is_length(3).contains("admin")
67
64
  ```
68
65
 
69
- ## Type-aware autocomplete
66
+ The real difference shows up when a test fails. Here a nested response has two wrong
67
+ fields. Plain `assert` dumps both structures and leaves you to find them:
68
+
69
+ ```text
70
+ assert response == expected
71
+ E AssertionError: assert {'id': 1, ...} == {'id': 1, ...}
72
+ E Omitting 1 identical items, use -vv to show
73
+ E Differing items:
74
+ E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Alice', 'role': 'admin'}}
75
+ E {'status': 'active'} != {'status': 'disabled'}
76
+ ```
77
+
78
+ assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs), in color:
79
+
80
+ ```python
81
+ assert_that(response).is_equal_to(expected)
82
+ ```
83
+
84
+ <img src="docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: status and user.role shown with their paths, removals in red and additions in green">
85
+
86
+ Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
87
+ For responses with dynamic fields (IDs, timestamps), validate a subset with
88
+ [`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
89
+
90
+ ## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
70
91
 
71
92
  `assert_that()` uses `@overload` to return type-specific Protocols.
72
93
  Your IDE shows only methods relevant to the value you're testing, not all 100+:
@@ -79,6 +100,8 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
79
100
 
80
101
  9 type-specific Protocols instead of one `Any`. Works in PyCharm, VS Code, and any LSP-compatible editor.
81
102
 
103
+ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) guide for the full walkthrough.
104
+
82
105
  ---
83
106
 
84
107
  ## Features
@@ -86,7 +109,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
86
109
  **Fluent API**
87
110
 
88
111
  - [**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.
112
+ - [**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
113
  - [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
91
114
  - [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
92
115
  - [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
@@ -105,8 +128,9 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
105
128
  - [**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
129
  - [**Async assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` with polling/retry for eventual consistency.
107
130
  - [**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.
131
+ - [**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
132
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
133
+ - **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
110
134
 
111
135
  **Type safety**
112
136
 
@@ -128,7 +152,7 @@ See the [full documentation](https://solganis.github.io/assertpy2/) for all asse
128
152
 
129
153
  ---
130
154
 
131
- ## Integrations
155
+ ## [Integrations](https://solganis.github.io/assertpy2/integrations/)
132
156
 
133
157
  ### Allure
134
158
 
@@ -176,6 +200,8 @@ def step_impl(context, age):
176
200
 
177
201
  Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.
178
202
 
203
+ See the [Integrations guide](https://solganis.github.io/assertpy2/integrations/) for attachment modes, configuration, and full examples.
204
+
179
205
  ---
180
206
 
181
207
  <p align="center">
@@ -0,0 +1,66 @@
1
+ """Runtime-checkable protocols for duck-typed object introspection.
2
+
3
+ The comparison and diff code accepts arbitrary user objects, so it inspects them structurally: pydantic
4
+ models (``model_dump``), ``attrs`` classes (``__attrs_attrs__``) and namedtuples (``_fields`` /
5
+ ``_asdict``). Expressing those shapes as ``@runtime_checkable`` protocols lets the diff code use
6
+ ``isinstance`` checks the type checker can follow - narrowing the value instead of probing it with
7
+ ``hasattr`` and then suppressing the attribute access.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Protocol, TypeGuard, runtime_checkable
13
+
14
+
15
+ @runtime_checkable
16
+ class SupportsModelDump(Protocol):
17
+ """A pydantic-style model exposing ``model_dump()``."""
18
+
19
+ def model_dump(self) -> dict[str, Any]: ...
20
+
21
+
22
+ @runtime_checkable
23
+ class NamedTupleLike(Protocol):
24
+ """A ``collections.namedtuple`` / ``typing.NamedTuple`` instance."""
25
+
26
+ _fields: tuple[str, ...]
27
+
28
+ def _asdict(self) -> dict[str, Any]: ...
29
+
30
+
31
+ @runtime_checkable
32
+ class AttrsInstance(Protocol):
33
+ """An ``attrs``-decorated instance exposing ``__attrs_attrs__``."""
34
+
35
+ __attrs_attrs__: tuple[Any, ...]
36
+
37
+
38
+ @runtime_checkable
39
+ class MappingLike(Protocol):
40
+ """A dict-like object that can be iterated over and subscripted by key."""
41
+
42
+ def keys(self) -> Any: ...
43
+
44
+ def __iter__(self) -> Any: ...
45
+
46
+ def __getitem__(self, key: Any) -> Any: ...
47
+
48
+
49
+ def is_model_dump_object(obj: object) -> TypeGuard[SupportsModelDump]:
50
+ """Return whether ``obj`` exposes a callable ``model_dump()`` (e.g. a pydantic model)."""
51
+ return isinstance(obj, SupportsModelDump) and callable(obj.model_dump)
52
+
53
+
54
+ def is_namedtuple(obj: object) -> TypeGuard[NamedTupleLike]:
55
+ """Return whether ``obj`` is a namedtuple instance (a ``tuple`` carrying ``_fields``/``_asdict``)."""
56
+ return isinstance(obj, tuple) and isinstance(obj, NamedTupleLike)
57
+
58
+
59
+ def is_attrs_instance(obj: object) -> TypeGuard[AttrsInstance]:
60
+ """Return whether ``obj`` is an ``attrs``-decorated instance."""
61
+ return isinstance(obj, AttrsInstance)
62
+
63
+
64
+ def is_mapping_like(obj: object) -> TypeGuard[MappingLike]:
65
+ """Return whether ``obj`` is dict-like: iterable with ``keys()`` and ``[]`` access."""
66
+ return isinstance(obj, MappingLike) and callable(obj.keys)
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
4
3
  from typing import TYPE_CHECKING, Any
5
4
 
6
5
  if TYPE_CHECKING:
6
+ import logging
7
+
7
8
  from ._compat import Self
8
9
  from .errors import DiffResult
9
10
 
@@ -9,17 +9,18 @@ import logging
9
9
  import os
10
10
  import sys
11
11
  import types
12
- from collections.abc import Callable, Iterator
13
- from typing import TYPE_CHECKING, overload
12
+ from typing import TYPE_CHECKING, Final, overload
14
13
 
15
14
  if TYPE_CHECKING:
16
15
  import datetime
17
16
  import pathlib
17
+ from collections.abc import Callable, Iterator
18
18
 
19
19
  from ._compat import Self
20
20
  from ._typing import (
21
21
  _BytesAssertion,
22
22
  _CallableAssertion,
23
+ _CoreAssertion,
23
24
  _DateAssertion,
24
25
  _DictAssertion,
25
26
  _IterableAssertion,
@@ -47,13 +48,13 @@ from .snapshot import SnapshotMixin
47
48
  from .string import StringMixin
48
49
  from .warning import WarningMixin
49
50
 
50
- __version__ = "2.7.0"
51
+ __version__ = "2.8.1"
51
52
 
52
53
  __tracebackhide__ = True # clean tracebacks via py.test integration
53
54
  contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
54
55
 
55
56
  # assertpy files
56
- ASSERTPY_FILES = [
57
+ ASSERTPY_FILES: Final = [
57
58
  os.path.join("assertpy2", file)
58
59
  for file in [
59
60
  "assertpy.py",
@@ -253,11 +254,16 @@ def assert_that(val: bytes | bytearray, description: str = "") -> _BytesAssertio
253
254
  def assert_that(val: Callable[..., object], description: str = "") -> _CallableAssertion: ...
254
255
 
255
256
 
257
+ # Fallback returns the concrete AssertionBuilder so object- and union-typed values keep the full API.
258
+ # The specific protocols are not assignable to AssertionBuilder, so mypy --strict reports overload-overlap
259
+ # for each specific overload and pyright one reportOverlappingOverload; ty (the gate) does not flag it. Kept
260
+ # intentionally - returning _CoreAssertion here would strip type-specific assertions from object/union values.
256
261
  @overload
257
262
  def assert_that(val: object, description: str = "") -> AssertionBuilder: ...
258
263
 
259
264
 
260
- def assert_that(val, description=""):
265
+ # Return the common base protocol so each overload stays consistent with the impl (no reportInconsistentOverload).
266
+ def assert_that(val, description="") -> _CoreAssertion:
261
267
  """Set the value to be tested, plus an optional description, and allow assertions to be called.
262
268
 
263
269
  This is a factory method for the :class:`AssertionBuilder`, and the single most important
@@ -69,7 +69,7 @@ class AsyncAssertionBuilder:
69
69
  method = getattr(builder, name)
70
70
  method(*args, **kwargs)
71
71
  return builder
72
- except AssertionError as exc:
72
+ except AssertionError as exc: # noqa: PERF203 # retry-on-failure needs the try/except per poll iteration
73
73
  last_error = exc
74
74
  if loop.time() >= deadline:
75
75
  raise AssertionError(
@@ -2,8 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import collections.abc
4
4
  import dataclasses
5
- from typing import TYPE_CHECKING
5
+ from typing import TYPE_CHECKING, Any, Final
6
6
 
7
+ from ._introspection import is_model_dump_object, is_namedtuple
7
8
  from ._mixin_base import _MixinBase
8
9
  from .errors import DiffEntry, DiffResult
9
10
  from .matchers import Matcher, StructureMatcher
@@ -13,11 +14,7 @@ if TYPE_CHECKING:
13
14
 
14
15
  __tracebackhide__ = True
15
16
 
16
- _SENTINEL = object()
17
-
18
-
19
- def _is_model_dump_object(obj: object) -> bool:
20
- return hasattr(obj, "model_dump") and callable(obj.model_dump)
17
+ _SENTINEL: Final = object()
21
18
 
22
19
 
23
20
  class BaseMixin(_MixinBase):
@@ -80,7 +77,9 @@ class BaseMixin(_MixinBase):
80
77
  assert_that({'a': 1, 'b': 2, 'c': 3}).is_equal_to({'a': 1}, ignore=['b', 'c'])
81
78
 
82
79
  # ignore nested keys
83
- assert_that({'a': {'b': 2, 'c': 3, 'd': 4}}).is_equal_to({'a': {'d': 4}}, ignore=[('a', 'b'), ('a', 'c')])
80
+ assert_that({'a': {'b': 2, 'c': 3, 'd': 4}}).is_equal_to(
81
+ {'a': {'d': 4}}, ignore=[('a', 'b'), ('a', 'c')]
82
+ )
84
83
 
85
84
  When the val is *dict-like*, only certain keys can be *included* when checking equality::
86
85
 
@@ -133,8 +132,8 @@ class BaseMixin(_MixinBase):
133
132
  if self._dict_not_equal(self.val, other, ignore=ignore, include=include):
134
133
  self._dict_err(self.val, other, ignore=ignore, include=include)
135
134
  elif ignore or include:
136
- val_is_namedtuple = isinstance(self.val, tuple) and hasattr(self.val, "_fields")
137
- other_is_namedtuple = isinstance(other, tuple) and hasattr(other, "_fields")
135
+ val_is_namedtuple = is_namedtuple(self.val)
136
+ other_is_namedtuple = is_namedtuple(other)
138
137
  if (
139
138
  isinstance(self.val, (list, tuple))
140
139
  and isinstance(other, (list, tuple))
@@ -208,14 +207,9 @@ class BaseMixin(_MixinBase):
208
207
  return sub_entries
209
208
  return [DiffEntry(path=path, actual=actual_value, expected=expected_value)]
210
209
 
211
- if (
212
- hasattr(actual, "_fields")
213
- and isinstance(actual, tuple)
214
- and hasattr(expected, "_fields")
215
- and isinstance(expected, tuple)
216
- ):
210
+ if is_namedtuple(actual) and is_namedtuple(expected):
217
211
  entries: list[DiffEntry] = []
218
- for field in actual._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
212
+ for field in actual._fields:
219
213
  actual_value = getattr(actual, field)
220
214
  expected_value = getattr(expected, field, _SENTINEL)
221
215
  path = f"{_prefix}.{field}"
@@ -223,9 +217,11 @@ class BaseMixin(_MixinBase):
223
217
  entries.append(DiffEntry(path=path, actual=actual_value, expected=None))
224
218
  elif actual_value != expected_value:
225
219
  entries.extend(_field_entries(actual_value, expected_value, path))
226
- for field in expected._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
227
- if not hasattr(actual, field):
228
- entries.append(DiffEntry(path=f"{_prefix}.{field}", actual=None, expected=getattr(expected, field)))
220
+ entries.extend(
221
+ DiffEntry(path=f"{_prefix}.{field}", actual=None, expected=getattr(expected, field))
222
+ for field in expected._fields
223
+ if not hasattr(actual, field)
224
+ )
229
225
  return DiffResult(kind="namedtuple", entries=entries)
230
226
  if (
231
227
  dataclasses.is_dataclass(actual)
@@ -248,9 +244,9 @@ class BaseMixin(_MixinBase):
248
244
  if actual_value != expected_value:
249
245
  entries.extend(_field_entries(actual_value, expected_value, path))
250
246
  return DiffResult(kind="dataclass", entries=entries)
251
- if _is_model_dump_object(actual) and _is_model_dump_object(expected):
252
- actual_dict = actual.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
253
- expected_dict = expected.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
247
+ if is_model_dump_object(actual) and is_model_dump_object(expected):
248
+ actual_dict = actual.model_dump()
249
+ expected_dict = expected.model_dump()
254
250
  entries = []
255
251
  for key in sorted(set(actual_dict) | set(expected_dict)):
256
252
  path = f"{_prefix}.{key}" if _prefix else f".{key}"
@@ -354,37 +350,34 @@ class BaseMixin(_MixinBase):
354
350
  DiffEntry(path=f"{prefix}.{field.name}", actual=actual_value, expected=expected_value)
355
351
  )
356
352
  return entries or None
357
- if (
358
- hasattr(actual, "_fields")
359
- and isinstance(actual, tuple)
360
- and hasattr(expected, "_fields")
361
- and isinstance(expected, tuple)
362
- ):
353
+ if is_namedtuple(actual) and is_namedtuple(expected):
363
354
  child_seen = _seen | {id(actual), id(expected)}
364
355
  entries = []
365
- for field in actual._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
366
- actual_value = getattr(actual, field)
367
- expected_value = getattr(expected, field, _SENTINEL)
356
+ for field_name in actual._fields:
357
+ actual_value = getattr(actual, field_name)
358
+ expected_value = getattr(expected, field_name, _SENTINEL)
368
359
  if expected_value is _SENTINEL:
369
- entries.append(DiffEntry(path=f"{prefix}.{field}", actual=actual_value, expected=None))
360
+ entries.append(DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=None))
370
361
  elif actual_value != expected_value:
371
362
  sub_entries = BaseMixin._sub_diff_entries(
372
- actual_value, expected_value, f"{prefix}.{field}", _seen=child_seen
363
+ actual_value, expected_value, f"{prefix}.{field_name}", _seen=child_seen
373
364
  )
374
365
  if sub_entries is not None:
375
366
  entries.extend(sub_entries)
376
367
  else:
377
368
  entries.append(
378
- DiffEntry(path=f"{prefix}.{field}", actual=actual_value, expected=expected_value)
369
+ DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=expected_value)
379
370
  )
380
- for field in expected._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
381
- if not hasattr(actual, field):
382
- entries.append(DiffEntry(path=f"{prefix}.{field}", actual=None, expected=getattr(expected, field)))
371
+ for field_name in expected._fields:
372
+ if not hasattr(actual, field_name):
373
+ entries.append(
374
+ DiffEntry(path=f"{prefix}.{field_name}", actual=None, expected=getattr(expected, field_name))
375
+ )
383
376
  return entries or None
384
- if _is_model_dump_object(actual) and _is_model_dump_object(expected):
377
+ if is_model_dump_object(actual) and is_model_dump_object(expected):
385
378
  child_seen = _seen | {id(actual), id(expected)}
386
- actual_dict = actual.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
387
- expected_dict = expected.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
379
+ actual_dict = actual.model_dump()
380
+ expected_dict = expected.model_dump()
388
381
  entries = []
389
382
  for key in sorted(set(actual_dict) | set(expected_dict)):
390
383
  path = f"{prefix}.{key}"
@@ -430,7 +423,13 @@ class BaseMixin(_MixinBase):
430
423
  """
431
424
  if isinstance(matcher, Matcher):
432
425
  if not matcher.matches(self.val):
433
- return self.error(f"Expected {matcher.describe()}, but {matcher.describe_mismatch(self.val)}.")
426
+ description = matcher.describe()
427
+ return self.error(
428
+ f"Expected {description}, but {matcher.describe_mismatch(self.val)}.",
429
+ actual=self.val,
430
+ expected=description,
431
+ diff=DiffResult(kind="match", entries=[DiffEntry(path=".", actual=self.val, expected=description)]),
432
+ )
434
433
  elif callable(matcher):
435
434
  if not matcher(self.val):
436
435
  return self.error(f"Expected <{self.val}> to satisfy <{matcher}>, but did not.")
@@ -466,11 +465,17 @@ class BaseMixin(_MixinBase):
466
465
  if not isinstance(self.val, collections.abc.Iterable):
467
466
  raise TypeError("val is not iterable")
468
467
  if isinstance(matcher, Matcher):
468
+ description = matcher.describe()
469
469
  for i, item in enumerate(self.val):
470
470
  if not matcher.matches(item):
471
471
  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)}."
472
+ f"Expected all items to satisfy {description}, but item at index {i} <{item}> did not:"
473
+ f" {matcher.describe_mismatch(item)}.",
474
+ actual=item,
475
+ expected=description,
476
+ diff=DiffResult(
477
+ kind="match", entries=[DiffEntry(path=f"[{i}]", actual=item, expected=description)]
478
+ ),
474
479
  )
475
480
  elif callable(matcher):
476
481
  for i, item in enumerate(self.val):
@@ -482,7 +487,7 @@ class BaseMixin(_MixinBase):
482
487
  raise TypeError("given arg must be a Matcher or callable")
483
488
  return self
484
489
 
485
- def matches_structure(self, spec: dict) -> Self:
490
+ def matches_structure(self, spec: dict[Any, Any]) -> Self:
486
491
  """Asserts that val is a dict matching the given structure specification.
487
492
 
488
493
  Each key in ``spec`` maps to either a :class:`~assertpy2.matchers.Matcher`, a raw value
@@ -515,10 +520,17 @@ class BaseMixin(_MixinBase):
515
520
  if not isinstance(spec, dict):
516
521
  raise TypeError("given arg must be a dict")
517
522
  matcher = StructureMatcher(spec)
518
- if not matcher.matches(self.val):
523
+ mismatches = matcher.collect_mismatches(self.val)
524
+ if mismatches:
525
+ entries = [
526
+ DiffEntry(path=path, actual=actual, expected=description) for path, actual, description in mismatches
527
+ ]
519
528
  return self.error(
520
529
  f"Expected <{self.val}> to match structure {matcher.describe()}, but"
521
- f" {matcher.describe_mismatch(self.val)}."
530
+ f" {matcher.describe_mismatch(self.val)}.",
531
+ actual=self.val,
532
+ expected=spec,
533
+ diff=DiffResult(kind="match", entries=entries),
522
534
  )
523
535
  return self
524
536
 
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- _BOOL_TRUE = frozenset({"true", "yes", "1", "on"})
4
- _BOOL_FALSE = frozenset({"false", "no", "0", "off"})
3
+ from types import MappingProxyType
4
+ from typing import Final
5
+
6
+ _BOOL_TRUE: Final = frozenset({"true", "yes", "1", "on"})
7
+ _BOOL_FALSE: Final = frozenset({"false", "no", "0", "off"})
5
8
 
6
9
 
7
10
  def _with_pattern(pattern):
@@ -51,13 +54,15 @@ def _bool_like(text):
51
54
  raise ValueError(f"expected boolean-like value, got {text!r}")
52
55
 
53
56
 
54
- ASSERTPY_TYPES = {
55
- "PositiveInt": _positive_int,
56
- "NonNegativeInt": _non_negative_int,
57
- "PositiveFloat": _positive_float,
58
- "NonEmptyString": _non_empty_string,
59
- "BoolLike": _bool_like,
60
- }
57
+ ASSERTPY_TYPES: Final = MappingProxyType(
58
+ {
59
+ "PositiveInt": _positive_int,
60
+ "NonNegativeInt": _non_negative_int,
61
+ "PositiveFloat": _positive_float,
62
+ "NonEmptyString": _non_empty_string,
63
+ "BoolLike": _bool_like,
64
+ }
65
+ )
61
66
 
62
67
 
63
68
  def register_assertpy_types() -> None: