assertpy2 2.13.0__tar.gz → 2.14.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 (156) hide show
  1. {assertpy2-2.13.0 → assertpy2-2.14.0}/PKG-INFO +1 -2
  2. {assertpy2-2.13.0 → assertpy2-2.14.0}/README.md +0 -1
  3. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/__init__.py +3 -1
  4. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/_compare.py +14 -3
  5. assertpy2-2.14.0/assertpy2/_diff.py +346 -0
  6. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/_introspection.py +14 -1
  7. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/_mixin_base.py +1 -11
  8. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/_typing.py +17 -0
  9. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/assertpy.py +44 -13
  10. assertpy2-2.14.0/assertpy2/async_assertions.py +208 -0
  11. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/base.py +17 -343
  12. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/dataframe.py +9 -4
  13. assertpy2-2.14.0/assertpy2/errors.py +166 -0
  14. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/helpers.py +10 -9
  15. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/matchers.py +8 -2
  16. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/pytest_plugin.py +76 -11
  17. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/snapshot.py +7 -2
  18. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/errors.md +32 -0
  19. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/integrations.md +36 -4
  20. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/matchers.md +5 -0
  21. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/errors.md +12 -0
  22. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/testing.md +45 -0
  23. {assertpy2-2.13.0 → assertpy2-2.14.0}/pyproject.toml +1 -1
  24. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_async.py +213 -2
  25. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_dataframe.py +21 -4
  26. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_dict_compare.py +21 -0
  27. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_equals.py +9 -0
  28. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_errors.py +49 -0
  29. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_extensions.py +65 -0
  30. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_not.py +5 -0
  31. assertpy2-2.14.0/tests/test_protocol_parity.py +86 -0
  32. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_pytest_plugin.py +170 -14
  33. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_recursive_assertion.py +20 -20
  34. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_rich_diff.py +78 -78
  35. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_snapshots.py +19 -0
  36. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_structural.py +31 -0
  37. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_typing.py +26 -0
  38. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_warn.py +39 -23
  39. {assertpy2-2.13.0 → assertpy2-2.14.0}/uv.lock +1 -1
  40. assertpy2-2.13.0/assertpy2/async_assertions.py +0 -113
  41. assertpy2-2.13.0/assertpy2/errors.py +0 -51
  42. {assertpy2-2.13.0 → assertpy2-2.14.0}/.codecov.yml +0 -0
  43. {assertpy2-2.13.0 → assertpy2-2.14.0}/.gitattributes +0 -0
  44. {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/dependabot.yml +0 -0
  45. {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/ci.yml +0 -0
  46. {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/codeql.yml +0 -0
  47. {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/docs.yml +0 -0
  48. {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/mutation.yml +0 -0
  49. {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/publish.yml +0 -0
  50. {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/scorecard.yml +0 -0
  51. {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/zizmor.yml +0 -0
  52. {assertpy2-2.13.0 → assertpy2-2.14.0}/.gitignore +0 -0
  53. {assertpy2-2.13.0 → assertpy2-2.14.0}/CONTRIBUTING.md +0 -0
  54. {assertpy2-2.13.0 → assertpy2-2.14.0}/LICENSE +0 -0
  55. {assertpy2-2.13.0 → assertpy2-2.14.0}/SECURITY.md +0 -0
  56. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/_compat.py +0 -0
  57. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/behave_matchers.py +0 -0
  58. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/bytes_mixin.py +0 -0
  59. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/collection.py +0 -0
  60. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/contains.py +0 -0
  61. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/date.py +0 -0
  62. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/dict.py +0 -0
  63. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/dynamic.py +0 -0
  64. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/exception.py +0 -0
  65. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/extracting.py +0 -0
  66. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/file.py +0 -0
  67. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/json_mixin.py +0 -0
  68. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/numeric.py +0 -0
  69. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/py.typed +0 -0
  70. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/string.py +0 -0
  71. {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/warning.py +0 -0
  72. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assertions.md +0 -0
  73. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-equal.png +0 -0
  74. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-equal.svg +0 -0
  75. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-gallery.png +0 -0
  76. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-match.svg +0 -0
  77. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-sequence.svg +0 -0
  78. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-set.svg +0 -0
  79. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/comparison.md +0 -0
  80. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/data.md +0 -0
  81. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/extending.md +0 -0
  82. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/fluent.md +0 -0
  83. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/getting-started.md +0 -0
  84. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/index.md +0 -0
  85. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/logo-dark.svg +0 -0
  86. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/logo.svg +0 -0
  87. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/migration.md +0 -0
  88. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/async.md +0 -0
  89. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/bytes.md +0 -0
  90. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/collections.md +0 -0
  91. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/containment.md +0 -0
  92. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/core.md +0 -0
  93. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/dataframes.md +0 -0
  94. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/dates.md +0 -0
  95. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/dicts.md +0 -0
  96. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/dynamic.md +0 -0
  97. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/entry-points.md +0 -0
  98. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/exceptions.md +0 -0
  99. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/extracting.md +0 -0
  100. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/files.md +0 -0
  101. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/json.md +0 -0
  102. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/matchers.md +0 -0
  103. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/numbers.md +0 -0
  104. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/overview.md +0 -0
  105. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/snapshots.md +0 -0
  106. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/strings.md +0 -0
  107. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/warnings.md +0 -0
  108. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/stylesheets/extra.css +0 -0
  109. {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/type-safety.md +0 -0
  110. {assertpy2-2.13.0 → assertpy2-2.14.0}/mkdocs.yml +0 -0
  111. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_behave_matchers.py +0 -0
  112. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_bool.py +0 -0
  113. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_bytes.py +0 -0
  114. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_callable.py +0 -0
  115. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_chaining.py +0 -0
  116. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_class.py +0 -0
  117. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_collection.py +0 -0
  118. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_core.py +0 -0
  119. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_custom_dict.py +0 -0
  120. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_custom_list.py +0 -0
  121. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_datetime.py +0 -0
  122. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_description.py +0 -0
  123. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_dict.py +0 -0
  124. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_dynamic.py +0 -0
  125. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_expected_exception.py +0 -0
  126. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_expected_warning.py +0 -0
  127. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_extracting.py +0 -0
  128. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_fail.py +0 -0
  129. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_file.py +0 -0
  130. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_grouped_soft.py +0 -0
  131. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_in.py +0 -0
  132. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_iterable_cluster.py +0 -0
  133. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_json.py +0 -0
  134. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_list.py +0 -0
  135. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_matcher_registry.py +0 -0
  136. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_matchers.py +0 -0
  137. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_mutation_hardening.py +0 -0
  138. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_namedtuple.py +0 -0
  139. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_none.py +0 -0
  140. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_numbers.py +0 -0
  141. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_overloads.py +0 -0
  142. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_pipeline.py +0 -0
  143. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_property_based.py +0 -0
  144. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_readme.py +0 -0
  145. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_recursive_compare.py +0 -0
  146. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_recursive_compare_attrs.py +0 -0
  147. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_recursive_compare_config.py +0 -0
  148. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_recursive_compare_pydantic.py +0 -0
  149. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_regex_groups.py +0 -0
  150. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_same_as.py +0 -0
  151. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_satisfy.py +0 -0
  152. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_soft.py +0 -0
  153. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_soft_fail.py +0 -0
  154. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_string.py +0 -0
  155. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_traceback.py +0 -0
  156. {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_type.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.13.0
3
+ Version: 2.14.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
@@ -188,7 +188,6 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
188
188
  - [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
189
189
  - [**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.
190
190
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
191
- - **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.
192
191
 
193
192
  **Type safety**
194
193
 
@@ -140,7 +140,6 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
140
140
  - [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
141
141
  - [**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.
142
142
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
143
- - **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.
144
143
 
145
144
  **Type safety**
146
145
 
@@ -13,7 +13,7 @@ from .assertpy import (
13
13
  soft_fail,
14
14
  )
15
15
  from .async_assertions import AsyncAssertionBuilder
16
- from .errors import AssertionFailure, DiffEntry, DiffResult
16
+ from .errors import AssertionFailure, DiffEntry, DiffResult, PollSample, PollTrace
17
17
  from .file import contents_of
18
18
  from .matchers import Matcher, clear_custom_matchers, match, register_matcher, unregister_matcher
19
19
  from .snapshot import SnapshotCreatedWarning
@@ -25,6 +25,8 @@ __all__ = [
25
25
  "DiffResult",
26
26
  "Matcher",
27
27
  "NegatedBuilder",
28
+ "PollSample",
29
+ "PollTrace",
28
30
  "SnapshotCreatedWarning",
29
31
  "SoftAssertionCollector",
30
32
  "WarningLoggingAdapter",
@@ -2,7 +2,7 @@
2
2
 
3
3
  ``is_equal_to`` builds a `_CompareConfig` from its ``tolerance``/``comparators`` kwargs and threads it
4
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
5
+ (`assertpy2._diff._sub_diff_entries()`, `HelpersMixin._dict_err()`). `_node_decision()` is the single
6
6
  switch both sides consult, so a tolerated or comparator-equal leaf is reported in neither. With ``config is
7
7
  None`` every helper reproduces the engine's historical ``actual != expected`` behavior exactly.
8
8
 
@@ -133,7 +133,7 @@ def _find_ambiguous_operand(actual, expected, _seen=None):
133
133
  return None
134
134
 
135
135
 
136
- def _guarded_not_equal(actual, expected) -> bool:
136
+ def _guarded_not_equal(actual, expected, *, method="is_equal_to") -> bool:
137
137
  """``bool(actual != expected)``, converting the ambiguity raised from *inside* a container's ``==``
138
138
  (where the top-level operand gate cannot see the array member) into the actionable ``TypeError``."""
139
139
  try:
@@ -142,7 +142,18 @@ def _guarded_not_equal(actual, expected) -> bool:
142
142
  operand = _find_ambiguous_operand(actual, expected)
143
143
  if operand is None:
144
144
  raise
145
- raise _array_equality_error("is_equal_to", operand) from error
145
+ raise _array_equality_error(method, operand) from error
146
+
147
+
148
+ def _guarded_equal(actual, expected, *, method) -> bool:
149
+ """``bool(actual == expected)`` with the same nested array/frame-like guard as `_guarded_not_equal`."""
150
+ try:
151
+ return bool(actual == expected)
152
+ except (ValueError, TypeError) as error:
153
+ operand = _find_ambiguous_operand(actual, expected)
154
+ if operand is None:
155
+ raise
156
+ raise _array_equality_error(method, operand) from error
146
157
 
147
158
 
148
159
  def _is_real_number(value) -> bool:
@@ -0,0 +1,346 @@
1
+ """Recursive diff engine shared by the equality assertions and the dict error path.
2
+
3
+ Three walkers coexist on purpose and must not be merged: `_build_equality_diff()` dispatches a
4
+ top-level pair (top-level dicts are handled by `HelpersMixin._dict_err()` instead, so its ladder
5
+ starts at namedtuples), `_sub_diff_entries()` decomposes nested values (mappings first), and
6
+ `_walk_leaves()` iterates scalar leaves for the recursive leaf assertions. Their dispatch orders
7
+ differ deliberately; a shared type classifier was investigated and rejected, because a single global
8
+ precedence changes behavior for values that quack like several container shapes at once.
9
+ `_sequence_diff_entries()` and `_dataclass_diff_entries()` are the pieces genuinely shared by the
10
+ diff builders.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ from typing import Final
17
+
18
+ from ._compare import _node_decision
19
+ from ._introspection import is_mapping_like, is_model_dump_object, is_namedtuple
20
+ from .errors import DiffEntry, DiffResult, _safe_repr, _safe_str
21
+
22
+ __tracebackhide__ = True
23
+
24
+ _SENTINEL: Final = object()
25
+
26
+
27
+ def _sequence_diff_entries(actual, expected, prefix, seen, config=None) -> list[DiffEntry]:
28
+ """Diff two sequences element-by-element, recursing into nested containers.
29
+
30
+ ``seen`` must already include the ids of ``actual``/``expected`` so a self-referential element
31
+ is caught. Shared by the top-level (`_build_equality_diff()`) and nested
32
+ (`_sub_diff_entries()`) paths so both decompose sequences identically. Elements have no field
33
+ name, so a ``config`` applies only type comparators and tolerance to them.
34
+ """
35
+ entries: list[DiffEntry] = []
36
+ max_len = max(len(actual), len(expected))
37
+ for i in range(max_len):
38
+ path = f"{prefix}[{i}]" if prefix else f"[{i}]"
39
+ if i >= len(actual):
40
+ entries.append(DiffEntry(path=path, actual=None, expected=expected[i]))
41
+ elif i >= len(expected):
42
+ entries.append(DiffEntry(path=path, actual=actual[i], expected=None))
43
+ else:
44
+ decision = _node_decision(actual[i], expected[i], config)
45
+ if decision == "leaf":
46
+ entries.append(DiffEntry(path=path, actual=actual[i], expected=expected[i]))
47
+ elif decision == "recurse":
48
+ sub_entries = _sub_diff_entries(actual[i], expected[i], path, _seen=seen, config=config)
49
+ if sub_entries is not None:
50
+ entries.extend(sub_entries)
51
+ else:
52
+ entries.append(DiffEntry(path=path, actual=actual[i], expected=expected[i]))
53
+ return entries
54
+
55
+
56
+ def _dataclass_diff_entries(actual, expected, prefix, seen, config=None) -> list[DiffEntry]:
57
+ """Diff two dataclasses over the sorted union of field names, both directions, recursing.
58
+
59
+ Reports fields present on only one side, and recurses into nested containers. ``seen`` must
60
+ already include the ids of ``actual``/``expected``. Shared by the top-level and nested paths
61
+ so both report dataclass fields identically.
62
+ """
63
+ entries: list[DiffEntry] = []
64
+ actual_names = {field.name for field in dataclasses.fields(actual)}
65
+ expected_names = {field.name for field in dataclasses.fields(expected)}
66
+ for field in sorted(actual_names | expected_names):
67
+ path = f"{prefix}.{field}"
68
+ if field not in expected_names:
69
+ entries.append(DiffEntry(path=path, actual=getattr(actual, field), expected=None))
70
+ elif field not in actual_names:
71
+ entries.append(DiffEntry(path=path, actual=None, expected=getattr(expected, field)))
72
+ else:
73
+ actual_value = getattr(actual, field)
74
+ expected_value = getattr(expected, field)
75
+ decision = _node_decision(actual_value, expected_value, config, field=field)
76
+ if decision == "leaf":
77
+ entries.append(DiffEntry(path=path, actual=actual_value, expected=expected_value))
78
+ elif decision == "recurse":
79
+ sub_entries = _sub_diff_entries(actual_value, expected_value, path, _seen=seen, config=config)
80
+ if sub_entries is not None:
81
+ entries.extend(sub_entries)
82
+ else:
83
+ entries.append(DiffEntry(path=path, actual=actual_value, expected=expected_value))
84
+ return entries
85
+
86
+
87
+ def _build_equality_diff(
88
+ actual: object, expected: object, *, _prefix: str = "", _seen: set[int] | None = None, config=None
89
+ ) -> DiffResult:
90
+ if _seen is None:
91
+ _seen = set()
92
+ pair_key = (id(actual), id(expected))
93
+ if pair_key[0] in _seen or pair_key[1] in _seen:
94
+ return DiffResult(
95
+ kind="scalar",
96
+ entries=[DiffEntry(path=_prefix or ".", actual="<circular ref>", expected="<circular ref>")],
97
+ )
98
+ _seen = _seen | {pair_key[0], pair_key[1]}
99
+
100
+ if config is not None:
101
+ decision = _node_decision(actual, expected, config)
102
+ if decision == "equal":
103
+ return DiffResult(kind="scalar", entries=[])
104
+ if decision == "leaf":
105
+ return DiffResult(kind="scalar", entries=[DiffEntry(path=_prefix or ".", actual=actual, expected=expected)])
106
+
107
+ def _field_entries(field_actual: object, field_expected: object, field_path: str) -> list[DiffEntry]:
108
+ nested = _sub_diff_entries(field_actual, field_expected, field_path, _seen=_seen, config=config)
109
+ if nested is not None:
110
+ return nested
111
+ return [DiffEntry(path=field_path, actual=field_actual, expected=field_expected)]
112
+
113
+ if is_namedtuple(actual) and is_namedtuple(expected):
114
+ entries: list[DiffEntry] = []
115
+ for field in actual._fields:
116
+ actual_value = getattr(actual, field)
117
+ expected_value = getattr(expected, field, _SENTINEL)
118
+ path = f"{_prefix}.{field}"
119
+ if expected_value is _SENTINEL:
120
+ entries.append(DiffEntry(path=path, actual=actual_value, expected=None))
121
+ else:
122
+ decision = _node_decision(actual_value, expected_value, config, field=field)
123
+ if decision == "leaf":
124
+ entries.append(DiffEntry(path=path, actual=actual_value, expected=expected_value))
125
+ elif decision == "recurse":
126
+ entries.extend(_field_entries(actual_value, expected_value, path))
127
+ entries.extend(
128
+ DiffEntry(path=f"{_prefix}.{field}", actual=None, expected=getattr(expected, field))
129
+ for field in expected._fields
130
+ if not hasattr(actual, field)
131
+ )
132
+ return DiffResult(kind="namedtuple", entries=entries)
133
+ if (
134
+ dataclasses.is_dataclass(actual)
135
+ and not isinstance(actual, type)
136
+ and dataclasses.is_dataclass(expected)
137
+ and not isinstance(expected, type)
138
+ ):
139
+ return DiffResult(
140
+ kind="dataclass",
141
+ entries=_dataclass_diff_entries(actual, expected, _prefix, _seen, config),
142
+ )
143
+ if is_model_dump_object(actual) and is_model_dump_object(expected):
144
+ actual_dict = actual.model_dump()
145
+ expected_dict = expected.model_dump()
146
+ entries = []
147
+ for key in sorted(set(actual_dict) | set(expected_dict)):
148
+ path = f"{_prefix}.{key}" if _prefix else f".{key}"
149
+ if key not in expected_dict:
150
+ entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=None))
151
+ elif key not in actual_dict:
152
+ entries.append(DiffEntry(path=path, actual=None, expected=expected_dict[key]))
153
+ else:
154
+ decision = _node_decision(actual_dict[key], expected_dict[key], config, field=key)
155
+ if decision == "leaf":
156
+ entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
157
+ elif decision == "recurse":
158
+ sub_entries = _sub_diff_entries(
159
+ actual_dict[key], expected_dict[key], path, _seen=_seen, config=config
160
+ )
161
+ if sub_entries is not None:
162
+ entries.extend(sub_entries)
163
+ else:
164
+ entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
165
+ return DiffResult(kind="model", entries=entries)
166
+ if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
167
+ return DiffResult(
168
+ kind="sequence",
169
+ entries=_sequence_diff_entries(actual, expected, _prefix, _seen, config),
170
+ )
171
+ if isinstance(actual, (set, frozenset)) and isinstance(expected, (set, frozenset)):
172
+ entries = []
173
+ for item in sorted(actual - expected, key=_safe_repr):
174
+ entries.append(DiffEntry(path="extra", actual=item, expected=None))
175
+ for item in sorted(expected - actual, key=_safe_repr):
176
+ entries.append(DiffEntry(path="missing", actual=None, expected=item))
177
+ return DiffResult(kind="set", entries=entries)
178
+ if isinstance(actual, str) and isinstance(expected, str):
179
+ entries = []
180
+ actual_lines = actual.splitlines()
181
+ expected_lines = expected.splitlines()
182
+ max_len = max(len(actual_lines), len(expected_lines))
183
+ for i in range(max_len):
184
+ if i >= len(actual_lines):
185
+ entries.append(DiffEntry(path=f"line {i + 1}", actual=None, expected=expected_lines[i]))
186
+ elif i >= len(expected_lines):
187
+ entries.append(DiffEntry(path=f"line {i + 1}", actual=actual_lines[i], expected=None))
188
+ elif actual_lines[i] != expected_lines[i]:
189
+ entries.append(DiffEntry(path=f"line {i + 1}", actual=actual_lines[i], expected=expected_lines[i]))
190
+ if not entries:
191
+ entries.append(DiffEntry(path=".", actual=actual, expected=expected))
192
+ return DiffResult(kind="string", entries=entries)
193
+ return DiffResult(kind="scalar", entries=[DiffEntry(path=_prefix or ".", actual=actual, expected=expected)])
194
+
195
+
196
+ def _sub_diff_entries(
197
+ actual: object, expected: object, prefix: str, *, _seen: set[int] | None = None, config=None
198
+ ) -> list[DiffEntry] | None:
199
+ """Canonical recursive diff for a value, returning path-level entries (or ``None`` for a leaf).
200
+
201
+ Recurses into mappings, dataclasses, namedtuples, model-dump objects and sequences, returning a
202
+ (possibly empty) list for those; anything else returns ``None`` so the caller renders a single
203
+ leaf entry. The empty-list-vs-``None`` distinction lets a caller tell a recursable value whose
204
+ children are all ``config``-tolerated (empty list, no entry) from a genuinely differing leaf
205
+ (``None``, one entry). This is the single nested engine shared by the top-level paths:
206
+ `_build_equality_diff()` (lists, dataclasses, ...) and the dict path
207
+ (`HelpersMixin._dict_err()`), which calls it with an empty ``prefix`` so the top-level dict
208
+ keys render bare (``b``) and nested keys render dotted (``u.b``).
209
+ """
210
+ if _seen is None:
211
+ _seen = set()
212
+ if id(actual) in _seen or id(expected) in _seen:
213
+ return [DiffEntry(path=prefix, actual="<circular ref>", expected="<circular ref>")]
214
+
215
+ if is_mapping_like(actual) and is_mapping_like(expected):
216
+ child_seen = _seen | {id(actual), id(expected)}
217
+ entries: list[DiffEntry] = []
218
+ actual_keys = set(actual)
219
+ expected_keys = set(expected)
220
+ for key in sorted(actual_keys | expected_keys, key=_safe_repr):
221
+ path = f"{prefix}.{_safe_str(key)}" if prefix else _safe_str(key)
222
+ if key not in expected_keys:
223
+ entries.append(DiffEntry(path=path, actual=actual[key], expected=None))
224
+ elif key not in actual_keys:
225
+ entries.append(DiffEntry(path=path, actual=None, expected=expected[key]))
226
+ else:
227
+ decision = _node_decision(actual[key], expected[key], config, field=key)
228
+ if decision == "leaf":
229
+ entries.append(DiffEntry(path=path, actual=actual[key], expected=expected[key]))
230
+ elif decision == "recurse":
231
+ sub_entries = _sub_diff_entries(actual[key], expected[key], path, _seen=child_seen, config=config)
232
+ if sub_entries is not None:
233
+ entries.extend(sub_entries)
234
+ else:
235
+ entries.append(DiffEntry(path=path, actual=actual[key], expected=expected[key]))
236
+ return entries
237
+ if (
238
+ dataclasses.is_dataclass(actual)
239
+ and not isinstance(actual, type)
240
+ and dataclasses.is_dataclass(expected)
241
+ and not isinstance(expected, type)
242
+ ):
243
+ child_seen = _seen | {id(actual), id(expected)}
244
+ return _dataclass_diff_entries(actual, expected, prefix, child_seen, config)
245
+ if is_namedtuple(actual) and is_namedtuple(expected):
246
+ child_seen = _seen | {id(actual), id(expected)}
247
+ entries = []
248
+ for field_name in actual._fields:
249
+ actual_value = getattr(actual, field_name)
250
+ expected_value = getattr(expected, field_name, _SENTINEL)
251
+ if expected_value is _SENTINEL:
252
+ entries.append(DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=None))
253
+ else:
254
+ decision = _node_decision(actual_value, expected_value, config, field=field_name)
255
+ if decision == "leaf":
256
+ entries.append(
257
+ DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=expected_value)
258
+ )
259
+ elif decision == "recurse":
260
+ sub_entries = _sub_diff_entries(
261
+ actual_value, expected_value, f"{prefix}.{field_name}", _seen=child_seen, config=config
262
+ )
263
+ if sub_entries is not None:
264
+ entries.extend(sub_entries)
265
+ else:
266
+ entries.append(
267
+ DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=expected_value)
268
+ )
269
+ for field_name in expected._fields:
270
+ if not hasattr(actual, field_name):
271
+ entries.append(
272
+ DiffEntry(path=f"{prefix}.{field_name}", actual=None, expected=getattr(expected, field_name))
273
+ )
274
+ return entries
275
+ if is_model_dump_object(actual) and is_model_dump_object(expected):
276
+ child_seen = _seen | {id(actual), id(expected)}
277
+ actual_dict = actual.model_dump()
278
+ expected_dict = expected.model_dump()
279
+ entries = []
280
+ for key in sorted(set(actual_dict) | set(expected_dict)):
281
+ path = f"{prefix}.{key}"
282
+ if key not in expected_dict:
283
+ entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=None))
284
+ elif key not in actual_dict:
285
+ entries.append(DiffEntry(path=path, actual=None, expected=expected_dict[key]))
286
+ else:
287
+ decision = _node_decision(actual_dict[key], expected_dict[key], config, field=key)
288
+ if decision == "leaf":
289
+ entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
290
+ elif decision == "recurse":
291
+ sub_entries = _sub_diff_entries(
292
+ actual_dict[key], expected_dict[key], path, _seen=child_seen, config=config
293
+ )
294
+ if sub_entries is not None:
295
+ entries.extend(sub_entries)
296
+ else:
297
+ entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
298
+ return entries
299
+ if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
300
+ child_seen = _seen | {id(actual), id(expected)}
301
+ return _sequence_diff_entries(actual, expected, prefix, child_seen, config)
302
+ return None
303
+
304
+
305
+ def _walk_leaves(value, prefix="", _seen=None):
306
+ """Yield ``(path, leaf)`` for every scalar leaf of an object graph, depth-first.
307
+
308
+ Recurses into the same containers as the rich-diff engine (`_sub_diff_entries()`): mappings,
309
+ dataclasses, namedtuples, model-dump objects, lists and tuples. Anything else - scalars, strings,
310
+ sets, opaque objects - is yielded as a single leaf, so the paths match the diffs. A circular
311
+ reference yields one ``(path, "<circular ref>")`` leaf and stops, mirroring the cycle guard.
312
+ """
313
+ if _seen is None:
314
+ _seen = set()
315
+ if id(value) in _seen:
316
+ yield (prefix or ".", "<circular ref>")
317
+ return
318
+ if is_mapping_like(value):
319
+ child_seen = _seen | {id(value)}
320
+ for key in value:
321
+ yield from _walk_leaves(value[key], f"{prefix}.{_safe_str(key)}" if prefix else _safe_str(key), child_seen)
322
+ return
323
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
324
+ child_seen = _seen | {id(value)}
325
+ for field in dataclasses.fields(value):
326
+ path = f"{prefix}.{field.name}" if prefix else field.name
327
+ yield from _walk_leaves(getattr(value, field.name), path, child_seen)
328
+ return
329
+ if is_namedtuple(value):
330
+ child_seen = _seen | {id(value)}
331
+ for field_name in value._fields:
332
+ path = f"{prefix}.{field_name}" if prefix else field_name
333
+ yield from _walk_leaves(getattr(value, field_name), path, child_seen)
334
+ return
335
+ if is_model_dump_object(value):
336
+ child_seen = _seen | {id(value)}
337
+ dumped = value.model_dump()
338
+ for key in dumped:
339
+ yield from _walk_leaves(dumped[key], f"{prefix}.{key}" if prefix else str(key), child_seen)
340
+ return
341
+ if isinstance(value, (list, tuple)):
342
+ child_seen = _seen | {id(value)}
343
+ for index, item in enumerate(value):
344
+ yield from _walk_leaves(item, f"{prefix}[{index}]" if prefix else f"[{index}]", child_seen)
345
+ return
346
+ yield (prefix or ".", value)
@@ -9,7 +9,14 @@ models (``model_dump``), ``attrs`` classes (``__attrs_attrs__``) and namedtuples
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from typing import Any, Protocol, TypeGuard, runtime_checkable
12
+ from typing import Any, Final, Protocol, TypeGuard, runtime_checkable
13
+
14
+ # Exact builtin types whose instances cannot carry protocol members (no instance ``__dict__``), so the
15
+ # expensive ``runtime_checkable`` isinstance (getattr_static per member) is skipped for them. Exact
16
+ # types only: subclasses always take the full structural check.
17
+ _ATOMIC_TYPES: Final = frozenset(
18
+ {type(None), bool, int, float, complex, str, bytes, bytearray, list, tuple, set, frozenset}
19
+ )
13
20
 
14
21
 
15
22
  @runtime_checkable
@@ -48,6 +55,8 @@ class MappingLike(Protocol):
48
55
 
49
56
  def is_model_dump_object(obj: object) -> TypeGuard[SupportsModelDump]:
50
57
  """Return whether ``obj`` exposes a callable ``model_dump()`` (e.g. a pydantic model)."""
58
+ if type(obj) is dict or type(obj) in _ATOMIC_TYPES:
59
+ return False
51
60
  return isinstance(obj, SupportsModelDump) and callable(obj.model_dump)
52
61
 
53
62
 
@@ -63,4 +72,8 @@ def is_attrs_instance(obj: object) -> TypeGuard[AttrsInstance]:
63
72
 
64
73
  def is_mapping_like(obj: object) -> TypeGuard[MappingLike]:
65
74
  """Return whether ``obj`` is dict-like: iterable with ``keys()`` and ``[]`` access."""
75
+ if type(obj) is dict:
76
+ return True
77
+ if type(obj) in _ATOMIC_TYPES:
78
+ return False
66
79
  return isinstance(obj, MappingLike) and callable(obj.keys)
@@ -7,7 +7,7 @@ if TYPE_CHECKING:
7
7
 
8
8
  from ._compare import _CompareConfig
9
9
  from ._compat import Self
10
- from .errors import DiffEntry, DiffResult
10
+ from .errors import DiffResult
11
11
 
12
12
 
13
13
  class _MixinBase:
@@ -99,16 +99,6 @@ class _MixinBase:
99
99
  @staticmethod
100
100
  def _to_comparable_dict(obj: object) -> dict[str, object] | None: ...
101
101
 
102
- @staticmethod
103
- def _sub_diff_entries(
104
- actual: object,
105
- expected: object,
106
- prefix: str,
107
- *,
108
- _seen: set[int] | None = ...,
109
- config: _CompareConfig | None = ...,
110
- ) -> list[DiffEntry] | None: ...
111
-
112
102
  # NumericMixin class attrs used by HelpersMixin._validate_between_args
113
103
  _NUMERIC_COMPAREABLE: frozenset[type]
114
104
  _NUMERIC_NON_COMPAREABLE: frozenset[type]
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  if TYPE_CHECKING:
6
+ import datetime
6
7
  from collections.abc import Callable, Hashable, Iterable
7
8
  from pathlib import Path
8
9
  from typing import Any, Protocol
@@ -81,6 +82,11 @@ if TYPE_CHECKING:
81
82
  def contains_any_of(self, *items: str) -> Self: ...
82
83
  def contains_none_of(self, *items: str) -> Self: ...
83
84
  def is_unicode(self) -> Self: ...
85
+ # NumericMixin - lexicographic ordering
86
+ def is_greater_than(self, other: str) -> Self: ...
87
+ def is_greater_than_or_equal_to(self, other: str) -> Self: ...
88
+ def is_less_than(self, other: str) -> Self: ...
89
+ def is_less_than_or_equal_to(self, other: str) -> Self: ...
84
90
  # ContainsMixin
85
91
  def contains(self, *items: object) -> Self: ...
86
92
  def does_not_contain(self, *items: object) -> Self: ...
@@ -215,6 +221,12 @@ if TYPE_CHECKING:
215
221
  def is_equal_to_ignoring_milliseconds(self, other: object) -> Self: ...
216
222
  def is_equal_to_ignoring_seconds(self, other: object) -> Self: ...
217
223
  def is_equal_to_ignoring_time(self, other: object) -> Self: ...
224
+ # NumericMixin - chronological ordering; at runtime operands must match val's exact type
225
+ def is_greater_than(self, other: datetime.date) -> Self: ...
226
+ def is_greater_than_or_equal_to(self, other: datetime.date) -> Self: ...
227
+ def is_less_than(self, other: datetime.date) -> Self: ...
228
+ def is_less_than_or_equal_to(self, other: datetime.date) -> Self: ...
229
+ def is_between(self, low: datetime.date, high: datetime.date) -> Self: ...
218
230
 
219
231
  class _PathAssertion(_CoreAssertion, Protocol):
220
232
  """Assertions available for ``pathlib.Path`` values."""
@@ -245,6 +257,11 @@ if TYPE_CHECKING:
245
257
  def has_byte_at(self, index: int, expected: int) -> Self: ...
246
258
  def is_hex_equal_to(self, expected_hex: str) -> Self: ...
247
259
  def decoded_as(self, encoding: str = ...) -> _StringAssertion: ...
260
+ # NumericMixin - lexicographic ordering
261
+ def is_greater_than(self, other: bytes | bytearray) -> Self: ...
262
+ def is_greater_than_or_equal_to(self, other: bytes | bytearray) -> Self: ...
263
+ def is_less_than(self, other: bytes | bytearray) -> Self: ...
264
+ def is_less_than_or_equal_to(self, other: bytes | bytearray) -> Self: ...
248
265
 
249
266
  class _InvokedAssertion(_StringAssertion, Protocol):
250
267
  """Assertions available after ``when_called_with()`` captured an exception/warning message.
@@ -49,7 +49,7 @@ from .snapshot import SnapshotMixin
49
49
  from .string import StringMixin
50
50
  from .warning import WarningMixin
51
51
 
52
- __version__ = "2.13.0"
52
+ __version__ = "2.14.0"
53
53
 
54
54
  __tracebackhide__ = True # clean tracebacks via py.test integration
55
55
  contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
@@ -410,7 +410,12 @@ def add_extension(func):
410
410
  """
411
411
  if not callable(func):
412
412
  raise TypeError("func must be callable")
413
- _extensions[func.__name__] = func
413
+ if isinstance(func, types.FunctionType):
414
+ # plain functions bind once here via the descriptor protocol, keeping assert_that() free of
415
+ # per-call grafting; the dedicated subclass keeps AssertionBuilder itself pristine on removal
416
+ setattr(_ExtendedBuilder, func.__name__, func)
417
+ else:
418
+ _extensions[func.__name__] = func
414
419
 
415
420
 
416
421
  def remove_extension(func):
@@ -428,13 +433,18 @@ def remove_extension(func):
428
433
  """
429
434
  if not callable(func):
430
435
  raise TypeError("func must be callable")
431
- if func.__name__ in _extensions:
432
- del _extensions[func.__name__]
436
+ if func.__name__ in vars(_ExtendedBuilder):
437
+ delattr(_ExtendedBuilder, func.__name__)
438
+ _extensions.pop(func.__name__, None)
433
439
 
434
440
 
435
441
  def _builder(val, description="", kind=None, expected=None, logger=None):
436
- """Internal helper to build a new `AssertionBuilder` instance and glue on any extension methods."""
437
- ab = AssertionBuilder(val, description, kind, expected, logger)
442
+ """Internal helper to build a new `AssertionBuilder` instance and glue on any extension methods.
443
+
444
+ Function extensions already live on `_ExtendedBuilder`; only non-function callables (which the
445
+ descriptor protocol cannot bind) still need per-instance grafting here.
446
+ """
447
+ ab = _ExtendedBuilder(val, description, kind, expected, logger)
438
448
  if _extensions:
439
449
  for name, func in _extensions.items():
440
450
  meth = types.MethodType(func, ab)
@@ -454,15 +464,19 @@ class WarningLoggingAdapter(logging.LoggerAdapter):
454
464
  frame = frame.f_back
455
465
 
456
466
  previous_frame = None
457
- for frame in reversed(
458
- frames
459
- ): # pragma: no branch - loop always finds an assertpy frame when called from error()
467
+ for frame in reversed(frames):
460
468
  for assertpy_filename in ASSERTPY_FILES:
461
469
  if frame[0].endswith(assertpy_filename):
462
470
  return previous_frame
463
471
  previous_frame = frame
464
-
465
- filename, lineno = _unwind(inspect.currentframe())
472
+ return None # pragma: no cover - error() is always reached through an assertpy frame
473
+
474
+ # a user file living under a directory named "assertpy2" can shadow every frame, so the
475
+ # location prefix is skipped rather than crashing the warning
476
+ caller = _unwind(inspect.currentframe())
477
+ if caller is None:
478
+ return msg, kwargs
479
+ filename, lineno = caller
466
480
  return f"[{os.path.basename(filename)}:{lineno}]: {msg}", kwargs
467
481
 
468
482
 
@@ -482,6 +496,10 @@ class NegatedBuilder:
482
496
  self._builder = builder
483
497
 
484
498
  def __getattr__(self, name: str) -> object:
499
+ if name == "eventually":
500
+ # eventually() switches the chain to polling instead of asserting, so "inverting" it is
501
+ # meaningless and would otherwise fail loudly with a misleading message
502
+ raise TypeError("eventually() cannot be negated with not_; assert the inverted condition instead")
485
503
  attr = getattr(self._builder, name)
486
504
  if not callable(attr):
487
505
  return attr
@@ -590,8 +608,9 @@ class AssertionBuilder(
590
608
  val: the value to be tested (aka the actual value)
591
609
  description (str, optional): the extra error message description. Defaults to ``''``
592
610
  (aka empty string)
593
- kind (str, optional): the kind of assertions, one of ``None``, ``soft``, or ``warn``.
594
- Defaults to ``None``
611
+ kind (str, optional): the failure mode of the assertions, one of ``None`` (raise),
612
+ ``soft`` (collect), or ``warn`` (log). Defaults to ``None``. Unrelated to
613
+ [`DiffResult.kind`][assertpy2.errors.DiffResult], which is a diff category
595
614
  expected (Error, optional): the expected exception. Defaults to ``None``
596
615
  logger (Logger, optional): the logger for warning messages. Defaults to ``None``
597
616
  """
@@ -650,6 +669,11 @@ class AssertionBuilder(
650
669
  while a service boots, a record not yet visible) can be retried too by listing those exception
651
670
  types in ``ignoring``.
652
671
 
672
+ Polling itself is always strict - retrying *requires* hard failures - but the final timeout
673
+ failure honors the builder's mode: inside
674
+ [`soft_assertions()`][assertpy2.assertpy.soft_assertions] it is collected instead of raised,
675
+ and under [`assert_warn()`][assertpy2.assertpy.assert_warn] it is logged.
676
+
653
677
  Args:
654
678
  timeout: maximum seconds to keep retrying (default ``5.0``)
655
679
  interval: seconds between retries (default ``0.5``)
@@ -695,4 +719,11 @@ class AssertionBuilder(
695
719
  timeout=timeout,
696
720
  interval=interval,
697
721
  ignoring=_normalize_ignoring(ignoring),
722
+ kind=self.kind,
723
+ logger=self.logger,
698
724
  )
725
+
726
+
727
+ class _ExtendedBuilder(AssertionBuilder):
728
+ """Host for user extensions: `add_extension()` installs plain functions here, so binding happens
729
+ once at registration and `AssertionBuilder` itself stays pristine when an extension is removed."""