assertpy2 2.11.0__tar.gz → 2.12.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 (154) hide show
  1. {assertpy2-2.11.0 → assertpy2-2.12.0}/.github/workflows/mutation.yml +2 -2
  2. {assertpy2-2.11.0 → assertpy2-2.12.0}/PKG-INFO +6 -7
  3. {assertpy2-2.11.0 → assertpy2-2.12.0}/README.md +5 -6
  4. assertpy2-2.12.0/assertpy2/_compare.py +136 -0
  5. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/_compat.py +1 -1
  6. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/_mixin_base.py +11 -3
  7. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/_typing.py +22 -4
  8. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/assertpy.py +47 -46
  9. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/async_assertions.py +1 -1
  10. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/base.py +432 -128
  11. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/collection.py +44 -13
  12. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/contains.py +77 -25
  13. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/dataframe.py +5 -4
  14. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/date.py +23 -19
  15. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/dict.py +16 -16
  16. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/dynamic.py +3 -3
  17. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/exception.py +21 -16
  18. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/extracting.py +18 -18
  19. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/file.py +16 -16
  20. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/helpers.py +106 -79
  21. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/json_mixin.py +6 -6
  22. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/matchers.py +34 -7
  23. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/numeric.py +40 -39
  24. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/snapshot.py +38 -38
  25. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/string.py +31 -30
  26. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/warning.py +18 -16
  27. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/assertions.md +64 -0
  28. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/comparison.md +9 -5
  29. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/errors.md +1 -1
  30. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/extending.md +3 -0
  31. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/fluent.md +7 -1
  32. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/getting-started.md +2 -1
  33. assertpy2-2.12.0/docs/index.md +103 -0
  34. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/integrations.md +3 -1
  35. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/migration.md +1 -1
  36. assertpy2-2.12.0/docs/reference/async.md +13 -0
  37. assertpy2-2.12.0/docs/reference/bytes.md +5 -0
  38. assertpy2-2.12.0/docs/reference/collections.md +5 -0
  39. assertpy2-2.12.0/docs/reference/containment.md +5 -0
  40. assertpy2-2.12.0/docs/reference/core.md +6 -0
  41. assertpy2-2.12.0/docs/reference/dataframes.md +5 -0
  42. assertpy2-2.12.0/docs/reference/dates.md +5 -0
  43. assertpy2-2.12.0/docs/reference/dicts.md +5 -0
  44. assertpy2-2.12.0/docs/reference/dynamic.md +6 -0
  45. assertpy2-2.12.0/docs/reference/entry-points.md +47 -0
  46. assertpy2-2.12.0/docs/reference/errors.md +23 -0
  47. assertpy2-2.12.0/docs/reference/exceptions.md +5 -0
  48. assertpy2-2.12.0/docs/reference/extracting.md +5 -0
  49. assertpy2-2.12.0/docs/reference/files.md +11 -0
  50. assertpy2-2.12.0/docs/reference/json.md +5 -0
  51. assertpy2-2.12.0/docs/reference/matchers.md +7 -0
  52. assertpy2-2.12.0/docs/reference/numbers.md +5 -0
  53. assertpy2-2.12.0/docs/reference/overview.md +21 -0
  54. assertpy2-2.12.0/docs/reference/snapshots.md +5 -0
  55. assertpy2-2.12.0/docs/reference/strings.md +6 -0
  56. assertpy2-2.12.0/docs/reference/warnings.md +5 -0
  57. assertpy2-2.12.0/docs/stylesheets/extra.css +13 -0
  58. assertpy2-2.12.0/mkdocs.yml +141 -0
  59. {assertpy2-2.11.0 → assertpy2-2.12.0}/pyproject.toml +8 -4
  60. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_bytes.py +20 -0
  61. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_chaining.py +3 -3
  62. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_dataframe.py +13 -0
  63. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_datetime.py +120 -120
  64. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_dict_compare.py +18 -18
  65. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_dynamic.py +17 -0
  66. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_extracting.py +20 -0
  67. assertpy2-2.12.0/tests/test_iterable_cluster.py +201 -0
  68. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_matchers.py +3 -3
  69. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_mutation_hardening.py +32 -0
  70. assertpy2-2.12.0/tests/test_recursive_assertion.py +194 -0
  71. assertpy2-2.12.0/tests/test_recursive_compare_config.py +274 -0
  72. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_typing.py +18 -0
  73. {assertpy2-2.11.0 → assertpy2-2.12.0}/uv.lock +215 -157
  74. assertpy2-2.11.0/docs/index.md +0 -55
  75. assertpy2-2.11.0/mkdocs.yml +0 -82
  76. {assertpy2-2.11.0 → assertpy2-2.12.0}/.codecov.yml +0 -0
  77. {assertpy2-2.11.0 → assertpy2-2.12.0}/.gitattributes +0 -0
  78. {assertpy2-2.11.0 → assertpy2-2.12.0}/.github/dependabot.yml +0 -0
  79. {assertpy2-2.11.0 → assertpy2-2.12.0}/.github/workflows/ci.yml +0 -0
  80. {assertpy2-2.11.0 → assertpy2-2.12.0}/.github/workflows/codeql.yml +0 -0
  81. {assertpy2-2.11.0 → assertpy2-2.12.0}/.github/workflows/docs.yml +0 -0
  82. {assertpy2-2.11.0 → assertpy2-2.12.0}/.github/workflows/publish.yml +0 -0
  83. {assertpy2-2.11.0 → assertpy2-2.12.0}/.github/workflows/scorecard.yml +0 -0
  84. {assertpy2-2.11.0 → assertpy2-2.12.0}/.github/workflows/zizmor.yml +0 -0
  85. {assertpy2-2.11.0 → assertpy2-2.12.0}/.gitignore +0 -0
  86. {assertpy2-2.11.0 → assertpy2-2.12.0}/CONTRIBUTING.md +0 -0
  87. {assertpy2-2.11.0 → assertpy2-2.12.0}/LICENSE +0 -0
  88. {assertpy2-2.11.0 → assertpy2-2.12.0}/SECURITY.md +0 -0
  89. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/__init__.py +0 -0
  90. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/_introspection.py +0 -0
  91. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/behave_matchers.py +0 -0
  92. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/bytes_mixin.py +0 -0
  93. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/errors.py +0 -0
  94. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/py.typed +0 -0
  95. {assertpy2-2.11.0 → assertpy2-2.12.0}/assertpy2/pytest_plugin.py +0 -0
  96. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/assets/diff-equal.png +0 -0
  97. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/assets/diff-equal.svg +0 -0
  98. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/assets/diff-gallery.png +0 -0
  99. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/assets/diff-match.svg +0 -0
  100. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/assets/diff-sequence.svg +0 -0
  101. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/assets/diff-set.svg +0 -0
  102. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/data.md +0 -0
  103. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/logo-dark.svg +0 -0
  104. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/logo.svg +0 -0
  105. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/matchers.md +0 -0
  106. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/testing.md +0 -0
  107. {assertpy2-2.11.0 → assertpy2-2.12.0}/docs/type-safety.md +0 -0
  108. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_async.py +0 -0
  109. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_behave_matchers.py +0 -0
  110. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_bool.py +0 -0
  111. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_callable.py +0 -0
  112. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_class.py +0 -0
  113. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_collection.py +0 -0
  114. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_core.py +0 -0
  115. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_custom_dict.py +0 -0
  116. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_custom_list.py +0 -0
  117. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_description.py +0 -0
  118. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_dict.py +0 -0
  119. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_equals.py +0 -0
  120. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_errors.py +0 -0
  121. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_expected_exception.py +0 -0
  122. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_expected_warning.py +0 -0
  123. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_extensions.py +0 -0
  124. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_fail.py +0 -0
  125. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_file.py +0 -0
  126. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_grouped_soft.py +0 -0
  127. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_in.py +0 -0
  128. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_json.py +0 -0
  129. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_list.py +0 -0
  130. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_matcher_registry.py +0 -0
  131. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_namedtuple.py +0 -0
  132. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_none.py +0 -0
  133. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_not.py +0 -0
  134. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_numbers.py +0 -0
  135. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_overloads.py +0 -0
  136. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_pipeline.py +0 -0
  137. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_property_based.py +0 -0
  138. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_pytest_plugin.py +0 -0
  139. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_readme.py +0 -0
  140. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_recursive_compare.py +0 -0
  141. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_recursive_compare_attrs.py +0 -0
  142. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_recursive_compare_pydantic.py +0 -0
  143. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_regex_groups.py +0 -0
  144. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_rich_diff.py +0 -0
  145. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_same_as.py +0 -0
  146. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_satisfy.py +0 -0
  147. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_snapshots.py +0 -0
  148. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_soft.py +0 -0
  149. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_soft_fail.py +0 -0
  150. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_string.py +0 -0
  151. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_structural.py +0 -0
  152. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_traceback.py +0 -0
  153. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_type.py +0 -0
  154. {assertpy2-2.11.0 → assertpy2-2.12.0}/tests/test_warn.py +0 -0
@@ -19,7 +19,7 @@ jobs:
19
19
  strategy:
20
20
  fail-fast: false
21
21
  matrix:
22
- module: [base, numeric, string, contains, collection, dict, date, matchers, helpers]
22
+ module: [base, numeric, string, contains, collection, dict, date, matchers, helpers, _compare, dataframe, json_mixin, snapshot, bytes_mixin, dynamic, extracting, warning, exception, async_assertions, file, behave_matchers]
23
23
  steps:
24
24
  - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
25
25
  with:
@@ -34,7 +34,7 @@ jobs:
34
34
  python-version: "3.14"
35
35
 
36
36
  - name: Install dependencies
37
- run: uv sync --extra json --group mutation
37
+ run: uv sync --extra json --extra behave --group mutation
38
38
 
39
39
  - name: Run mutation testing
40
40
  env:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.11.0
3
+ Version: 2.12.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
@@ -135,7 +135,7 @@ assert_that(response).is_equal_to(expected)
135
135
  <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: user.role shown with its path, removal in red and addition in green">
136
136
  </p>
137
137
 
138
- Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
138
+ Recursive diffs work for dicts, dataclasses, namedtuples, and Pydantic models.
139
139
  For responses with dynamic fields (IDs, timestamps), validate a subset with
140
140
  [`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
141
141
 
@@ -160,16 +160,16 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
160
160
 
161
161
  See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) guide for the full walkthrough.
162
162
 
163
- ---
164
-
165
163
  <h2 align="center">Features</h2>
166
164
 
167
165
  **Fluent API**
168
166
 
169
167
  - [**Composable matchers**](https://solganis.github.io/assertpy2/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
170
168
  - [**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.
169
+ - [**Recursive field assertions**](https://solganis.github.io/assertpy2/assertions/#recursive-field-assertions): `all_fields_satisfy()` / `has_no_none_fields()` apply a predicate to every leaf of an object graph, reporting the exact path.
171
170
  - [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
172
171
  - [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
172
+ - [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`.
173
173
  - [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
174
174
 
175
175
  **Built-in types**
@@ -177,7 +177,8 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
177
177
  - [Strings](https://solganis.github.io/assertpy2/assertions/#strings), [numbers](https://solganis.github.io/assertpy2/assertions/#numbers), [lists](https://solganis.github.io/assertpy2/assertions/#lists), [tuples](https://solganis.github.io/assertpy2/assertions/#tuples), [sets](https://solganis.github.io/assertpy2/assertions/#sets), [dicts](https://solganis.github.io/assertpy2/assertions/#dicts), [dates](https://solganis.github.io/assertpy2/assertions/#dates), [booleans](https://solganis.github.io/assertpy2/assertions/#booleans), [objects](https://solganis.github.io/assertpy2/assertions/#objects), [bytes](https://solganis.github.io/assertpy2/assertions/#bytes--bytearray), [files](https://solganis.github.io/assertpy2/assertions/#files), [exceptions](https://solganis.github.io/assertpy2/errors/#expected-exceptions).
178
178
  - [**Bytes assertions**](https://solganis.github.io/assertpy2/assertions/#bytes--bytearray): `is_valid_utf8()`, `starts_with_bytes()`, `is_hex_equal_to()`, `decoded_as()` for `bytes`/`bytearray`.
179
179
  - [**Dynamic assertions**](https://solganis.github.io/assertpy2/assertions/#dynamic-assertions-on-objects): `has_<name>()` for any attribute, property, or zero-argument method.
180
- - [**Dict comparison**](https://solganis.github.io/assertpy2/assertions/#selective-comparison-ignore--include): `is_equal_to()` with `ignore` and `include` for selective key/field matching (dicts, dataclasses, namedtuples, Pydantic models, attrs, plain objects).
180
+ - [**Dict comparison**](https://solganis.github.io/assertpy2/assertions/#selective-comparison-ignore--include): `is_equal_to()` with `ignore` and `include` for selective key/field matching (dicts, dataclasses, namedtuples, Pydantic models, attrs, plain objects), including by regex or type.
181
+ - [**Recursive comparison**](https://solganis.github.io/assertpy2/assertions/#recursive-comparison-tolerance--custom-comparators): `is_equal_to()` with `tolerance` (absolute float tolerance at any depth) or `comparators` (per-type / per-field predicates) for nested structures.
181
182
  - [**Extracting**](https://solganis.github.io/assertpy2/assertions/#extracting-attributes-from-objects): flatten collections on attributes with `filter` and `sort` support.
182
183
 
183
184
  **Testing**
@@ -202,8 +203,6 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
202
203
 
203
204
  See the [full documentation](https://solganis.github.io/assertpy2/) for all assertion methods, examples, and advanced features.
204
205
 
205
- ---
206
-
207
206
  <h2 align="center"><a href="https://solganis.github.io/assertpy2/integrations/">Integrations</a></h2>
208
207
 
209
208
  Optional adapters, each its own extra; full configuration and examples are in the
@@ -87,7 +87,7 @@ assert_that(response).is_equal_to(expected)
87
87
  <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: user.role shown with its path, removal in red and addition in green">
88
88
  </p>
89
89
 
90
- Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
90
+ Recursive diffs work for dicts, dataclasses, namedtuples, and Pydantic models.
91
91
  For responses with dynamic fields (IDs, timestamps), validate a subset with
92
92
  [`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
93
93
 
@@ -112,16 +112,16 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
112
112
 
113
113
  See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) guide for the full walkthrough.
114
114
 
115
- ---
116
-
117
115
  <h2 align="center">Features</h2>
118
116
 
119
117
  **Fluent API**
120
118
 
121
119
  - [**Composable matchers**](https://solganis.github.io/assertpy2/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
122
120
  - [**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.
121
+ - [**Recursive field assertions**](https://solganis.github.io/assertpy2/assertions/#recursive-field-assertions): `all_fields_satisfy()` / `has_no_none_fields()` apply a predicate to every leaf of an object graph, reporting the exact path.
123
122
  - [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
124
123
  - [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
124
+ - [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`.
125
125
  - [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
126
126
 
127
127
  **Built-in types**
@@ -129,7 +129,8 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
129
129
  - [Strings](https://solganis.github.io/assertpy2/assertions/#strings), [numbers](https://solganis.github.io/assertpy2/assertions/#numbers), [lists](https://solganis.github.io/assertpy2/assertions/#lists), [tuples](https://solganis.github.io/assertpy2/assertions/#tuples), [sets](https://solganis.github.io/assertpy2/assertions/#sets), [dicts](https://solganis.github.io/assertpy2/assertions/#dicts), [dates](https://solganis.github.io/assertpy2/assertions/#dates), [booleans](https://solganis.github.io/assertpy2/assertions/#booleans), [objects](https://solganis.github.io/assertpy2/assertions/#objects), [bytes](https://solganis.github.io/assertpy2/assertions/#bytes--bytearray), [files](https://solganis.github.io/assertpy2/assertions/#files), [exceptions](https://solganis.github.io/assertpy2/errors/#expected-exceptions).
130
130
  - [**Bytes assertions**](https://solganis.github.io/assertpy2/assertions/#bytes--bytearray): `is_valid_utf8()`, `starts_with_bytes()`, `is_hex_equal_to()`, `decoded_as()` for `bytes`/`bytearray`.
131
131
  - [**Dynamic assertions**](https://solganis.github.io/assertpy2/assertions/#dynamic-assertions-on-objects): `has_<name>()` for any attribute, property, or zero-argument method.
132
- - [**Dict comparison**](https://solganis.github.io/assertpy2/assertions/#selective-comparison-ignore--include): `is_equal_to()` with `ignore` and `include` for selective key/field matching (dicts, dataclasses, namedtuples, Pydantic models, attrs, plain objects).
132
+ - [**Dict comparison**](https://solganis.github.io/assertpy2/assertions/#selective-comparison-ignore--include): `is_equal_to()` with `ignore` and `include` for selective key/field matching (dicts, dataclasses, namedtuples, Pydantic models, attrs, plain objects), including by regex or type.
133
+ - [**Recursive comparison**](https://solganis.github.io/assertpy2/assertions/#recursive-comparison-tolerance--custom-comparators): `is_equal_to()` with `tolerance` (absolute float tolerance at any depth) or `comparators` (per-type / per-field predicates) for nested structures.
133
134
  - [**Extracting**](https://solganis.github.io/assertpy2/assertions/#extracting-attributes-from-objects): flatten collections on attributes with `filter` and `sort` support.
134
135
 
135
136
  **Testing**
@@ -154,8 +155,6 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
154
155
 
155
156
  See the [full documentation](https://solganis.github.io/assertpy2/) for all assertion methods, examples, and advanced features.
156
157
 
157
- ---
158
-
159
158
  <h2 align="center"><a href="https://solganis.github.io/assertpy2/integrations/">Integrations</a></h2>
160
159
 
161
160
  Optional adapters, each its own extra; full configuration and examples are in the
@@ -0,0 +1,136 @@
1
+ """Per-call configuration for tolerant / custom-comparator equality, shared by the equality and diff code.
2
+
3
+ ``is_equal_to`` builds a `_CompareConfig` from its ``tolerance``/``comparators`` kwargs and threads it
4
+ through both the boolean comparison (`HelpersMixin._dict_not_equal()`) and the diff/message rendering
5
+ (`BaseMixin._sub_diff_entries()`, `HelpersMixin._dict_err()`). `_node_decision()` is the single
6
+ switch both sides consult, so a tolerated or comparator-equal leaf is reported in neither. With ``config is
7
+ None`` every helper reproduces the engine's historical ``actual != expected`` behavior exactly.
8
+
9
+ Following the package convention, the impl helpers take unannotated args (the typed public surface lives in
10
+ `assertpy2._typing`); they operate on arbitrary user values whose operators ``numbers.Number`` cannot
11
+ express to the type checker.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import numbers
18
+ import re
19
+ from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable
24
+
25
+
26
+ @dataclass(frozen=True, slots=True, kw_only=True)
27
+ class _CompareConfig:
28
+ """Tolerance and custom comparators for a single ``is_equal_to`` call.
29
+
30
+ ``tolerance`` is an absolute tolerance applied to real-number leaves; ``comparators`` maps a ``type`` or
31
+ an immediate field name to a ``(actual, expected) -> bool`` predicate that owns matching leaves.
32
+ """
33
+
34
+ tolerance: float | None = None
35
+ comparators: dict[object, Callable[[object, object], bool]] | None = None
36
+
37
+
38
+ def _build_compare_config(tolerance, comparators) -> _CompareConfig | None:
39
+ """Validate the ``is_equal_to`` ``tolerance``/``comparators`` kwargs and build a config (``None`` if neither).
40
+
41
+ ``tolerance`` must be a non-negative real number (not ``bool``/``complex``/``NaN``); ``comparators`` must be a
42
+ dict of ``(actual, expected) -> bool`` callables keyed by ``type`` or field name.
43
+ """
44
+ if tolerance is None and comparators is None:
45
+ return None
46
+ if tolerance is not None:
47
+ if isinstance(tolerance, bool) or not isinstance(tolerance, numbers.Number) or isinstance(tolerance, complex):
48
+ raise TypeError("given tolerance arg must be a real number")
49
+ if math.isnan(tolerance):
50
+ raise ValueError("given tolerance arg must not be NaN")
51
+ if tolerance < 0:
52
+ raise ValueError("given tolerance arg must not be negative")
53
+ if comparators is not None:
54
+ if not isinstance(comparators, dict):
55
+ raise TypeError("given comparators arg must be a dict")
56
+ for comparator in comparators.values():
57
+ if not callable(comparator):
58
+ raise TypeError("each comparator must be callable")
59
+ return _CompareConfig(tolerance=tolerance, comparators=comparators)
60
+
61
+
62
+ def _is_real_number(value) -> bool:
63
+ """Return whether ``value`` is a real number eligible for tolerance (excludes ``bool`` and ``complex``).
64
+
65
+ Array/frame-likes are not `numbers.Number`, so they are excluded too - tolerance never triggers
66
+ their element-wise ``==`` that has no single truth value.
67
+ """
68
+ return isinstance(value, numbers.Number) and not isinstance(value, (bool, complex))
69
+
70
+
71
+ def _within_tolerance(actual, expected, tolerance) -> bool:
72
+ """Return whether two real numbers are within ``tolerance`` (absolute); ``NaN`` is never within.
73
+
74
+ Only actual ``float`` operands are checked for ``NaN`` (other reals cannot be ``NaN``), which also keeps
75
+ ``math.isnan`` off arbitrary-precision ``int`` values that would overflow it.
76
+ """
77
+ if isinstance(actual, float) and math.isnan(actual):
78
+ return False
79
+ if isinstance(expected, float) and math.isnan(expected):
80
+ return False
81
+ return abs(actual - expected) <= tolerance
82
+
83
+
84
+ def _resolve_comparator(actual, config: _CompareConfig, *, field):
85
+ """Resolve the comparator owning a leaf: immediate field name first, then exact type, then ``isinstance``.
86
+
87
+ ``field`` is the leaf's immediate key/field name (``None`` for sequence elements and bare scalars, which
88
+ have no name). Returns ``None`` when no comparator applies, so the caller falls back to tolerance / ``==``.
89
+ """
90
+ comparators = config.comparators
91
+ if comparators is None:
92
+ return None
93
+ if field is not None and not isinstance(field, type) and field in comparators:
94
+ return comparators[field]
95
+ if type(actual) in comparators:
96
+ return comparators[type(actual)]
97
+ for key, comparator in comparators.items():
98
+ if isinstance(key, type) and isinstance(actual, key):
99
+ return comparator
100
+ return None
101
+
102
+
103
+ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=None) -> str:
104
+ """Classify a node as ``"equal"``, ``"leaf"`` or ``"recurse"``.
105
+
106
+ With ``config is None`` this is exactly the engine's historical behavior: differing values ``"recurse"``
107
+ (to decompose into a sub-diff), equal values are ``"equal"`` (skipped); ``"leaf"`` never occurs. With a
108
+ config, a matching comparator or tolerance owns the node - it is classified ``"equal"`` or ``"leaf"`` and
109
+ never recursed into.
110
+ """
111
+ if config is not None:
112
+ comparator = _resolve_comparator(actual, config, field=field)
113
+ if comparator is not None:
114
+ return "equal" if comparator(actual, expected) else "leaf"
115
+ if config.tolerance is not None and _is_real_number(actual) and _is_real_number(expected):
116
+ return "equal" if _within_tolerance(actual, expected, config.tolerance) else "leaf"
117
+ return "recurse" if actual != expected else "equal"
118
+
119
+
120
+ def _spec_matches(key, value, specs) -> bool:
121
+ """Return whether a dict ``key``/``value`` matches any ignore/include ``spec``.
122
+
123
+ A spec matches by exact key equality (today's behavior), by a compiled `re.Pattern` searched
124
+ against ``str(key)``, or by a ``type`` the ``value`` is an instance of. Nested-path tuples never match
125
+ here - they are expanded by the recursion in `HelpersMixin._dict_not_equal()`.
126
+ """
127
+ for spec in specs:
128
+ if isinstance(spec, re.Pattern):
129
+ if spec.search(str(key)):
130
+ return True
131
+ elif isinstance(spec, type):
132
+ if isinstance(value, spec):
133
+ return True
134
+ elif spec == key:
135
+ return True
136
+ return False
@@ -1,6 +1,6 @@
1
1
  """Internal compatibility shims.
2
2
 
3
- ``Self`` entered the standard library's :mod:`typing` in Python 3.11; on 3.10 it is provided by
3
+ ``Self`` entered the standard library's `typing` in Python 3.11; on 3.10 it is provided by
4
4
  ``typing_extensions``. Re-exporting it from one place lets every mixin import ``Self`` without repeating
5
5
  the version gate, and lets ``typing_extensions`` be dropped as a runtime dependency on Python 3.11+.
6
6
  """
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any
5
5
  if TYPE_CHECKING:
6
6
  import logging
7
7
 
8
+ from ._compare import _CompareConfig
8
9
  from ._compat import Self
9
10
  from .errors import DiffEntry, DiffResult
10
11
 
@@ -60,7 +61,7 @@ class _MixinBase:
60
61
 
61
62
  def _is_dict_like(
62
63
  self,
63
- d: object,
64
+ candidate: object,
64
65
  check_keys: bool = ...,
65
66
  check_values: bool = ...,
66
67
  check_getitem: bool = ...,
@@ -68,7 +69,7 @@ class _MixinBase:
68
69
 
69
70
  def _require_dict_like(
70
71
  self,
71
- d: object,
72
+ candidate: object,
72
73
  check_keys: bool = ...,
73
74
  check_values: bool = ...,
74
75
  check_getitem: bool = ...,
@@ -83,6 +84,7 @@ class _MixinBase:
83
84
  other: object,
84
85
  ignore: object = ...,
85
86
  include: object = ...,
87
+ config: _CompareConfig | None = ...,
86
88
  ) -> bool: ...
87
89
 
88
90
  def _dict_err(
@@ -91,6 +93,7 @@ class _MixinBase:
91
93
  other: object,
92
94
  ignore: object = ...,
93
95
  include: object = ...,
96
+ config: _CompareConfig | None = ...,
94
97
  ) -> None: ...
95
98
 
96
99
  @staticmethod
@@ -98,7 +101,12 @@ class _MixinBase:
98
101
 
99
102
  @staticmethod
100
103
  def _sub_diff_entries(
101
- actual: object, expected: object, prefix: str, *, _seen: set[int] | None = ...
104
+ actual: object,
105
+ expected: object,
106
+ prefix: str,
107
+ *,
108
+ _seen: set[int] | None = ...,
109
+ config: _CompareConfig | None = ...,
102
110
  ) -> list[DiffEntry] | None: ...
103
111
 
104
112
  # NumericMixin class attrs used by HelpersMixin._validate_between_args
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  if TYPE_CHECKING:
6
- from collections.abc import Callable, Hashable
6
+ from collections.abc import Callable, Hashable, Iterable
7
7
  from pathlib import Path
8
8
  from typing import Any, Protocol
9
9
 
@@ -21,7 +21,13 @@ if TYPE_CHECKING:
21
21
  # BaseMixin
22
22
  def described_as(self, description: str) -> Self: ...
23
23
  def is_equal_to(
24
- self, other: object, *, ignore: _KeySpecs | None = ..., include: _KeySpecs | None = ...
24
+ self,
25
+ other: object,
26
+ *,
27
+ ignore: _KeySpecs | None = ...,
28
+ include: _KeySpecs | None = ...,
29
+ tolerance: float | None = ...,
30
+ comparators: dict[object, Callable[..., bool]] | None = ...,
25
31
  ) -> Self: ...
26
32
  def is_not_equal_to(self, other: object) -> Self: ...
27
33
  def is_same_as(self, other: object) -> Self: ...
@@ -36,6 +42,8 @@ if TYPE_CHECKING:
36
42
  def is_callable(self) -> Self: ...
37
43
  def is_not_callable(self) -> Self: ...
38
44
  def satisfies(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
45
+ def all_fields_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
46
+ def has_no_none_fields(self) -> Self: ...
39
47
  # ContainsMixin - universal
40
48
  def is_in(self, *items: object) -> Self: ...
41
49
  def is_not_in(self, *items: object) -> Self: ...
@@ -73,8 +81,11 @@ if TYPE_CHECKING:
73
81
  def does_not_contain_duplicates(self) -> Self: ...
74
82
  def contains_exactly(self, *items: object) -> Self: ...
75
83
  def contains_in_order(self, *items: object) -> Self: ...
84
+ def contains_only_once(self, *items: object) -> Self: ...
85
+ def has_same_size_as(self, other: object) -> Self: ...
76
86
  def is_empty(self) -> Self: ...
77
87
  def is_not_empty(self) -> Self: ...
88
+ def is_subset_of(self, *supersets: object) -> Self: ...
78
89
  # StringMixin - regex
79
90
  def extracting_group(self, pattern: str, group: int | str = ...) -> Self: ...
80
91
  def matches_with_groups(self, pattern: str) -> Self: ...
@@ -138,6 +149,10 @@ if TYPE_CHECKING:
138
149
  def any_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
139
150
  def all_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
140
151
  def none_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
152
+ def satisfies_exactly(self, *matchers: Matcher | Callable[..., bool]) -> Self: ...
153
+ def zip_satisfies(self, other: Iterable[object], predicate: Callable[..., bool]) -> Self: ...
154
+ def contains_only_once(self, *items: object) -> Self: ...
155
+ def has_same_size_as(self, other: object) -> Self: ...
141
156
  # CollectionMixin - pipeline
142
157
  def filtered_on(self, predicate: Callable[..., bool]) -> Self: ...
143
158
  def mapped(self, func: Callable[..., object]) -> Self: ...
@@ -166,6 +181,7 @@ if TYPE_CHECKING:
166
181
  # ContainsMixin
167
182
  def contains(self, *items: object) -> Self: ...
168
183
  def does_not_contain(self, *items: object) -> Self: ...
184
+ def has_same_size_as(self, other: object) -> Self: ...
169
185
  def is_empty(self) -> Self: ...
170
186
  def is_not_empty(self) -> Self: ...
171
187
  # BaseMixin
@@ -209,6 +225,7 @@ if TYPE_CHECKING:
209
225
 
210
226
  # ContainsMixin
211
227
  def contains(self, *items: object) -> Self: ...
228
+ def has_same_size_as(self, other: object) -> Self: ...
212
229
  def is_empty(self) -> Self: ...
213
230
  def is_not_empty(self) -> Self: ...
214
231
  # BytesMixin
@@ -223,8 +240,9 @@ if TYPE_CHECKING:
223
240
  class _InvokedAssertion(_StringAssertion, Protocol):
224
241
  """Assertions available after ``when_called_with()`` captured an exception/warning message.
225
242
 
226
- The captured message is a ``str`` (hence the string assertions); :meth:`returned` pivots to
227
- the value the callable returned. Its static type is unknown, so ``returned()`` exposes the
243
+ The captured message is a ``str`` (hence the string assertions);
244
+ [`returned()`][assertpy2.exception.ExceptionMixin.returned] pivots to the value the callable
245
+ returned. Its static type is unknown, so ``returned()`` exposes the
228
246
  type-agnostic core assertions (``is_equal_to``, ``is_instance_of``, ``satisfies``, ...) -
229
247
  type-safe by construction, never advertising methods that may not apply.
230
248
  """