assertpy2 2.6.0__tar.gz → 2.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. {assertpy2-2.6.0 → assertpy2-2.7.0}/.gitignore +1 -0
  2. {assertpy2-2.6.0 → assertpy2-2.7.0}/PKG-INFO +1 -1
  3. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/_mixin_base.py +2 -1
  4. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/_typing.py +13 -1
  5. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/assertpy.py +3 -2
  6. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/exception.py +29 -1
  7. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/warning.py +6 -3
  8. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/errors.md +11 -0
  9. {assertpy2-2.6.0 → assertpy2-2.7.0}/pyproject.toml +2 -1
  10. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_expected_exception.py +14 -0
  11. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_expected_warning.py +26 -0
  12. assertpy2-2.7.0/tests/test_property_based.py +333 -0
  13. {assertpy2-2.6.0 → assertpy2-2.7.0}/uv.lock +25 -1
  14. {assertpy2-2.6.0 → assertpy2-2.7.0}/.codecov.yml +0 -0
  15. {assertpy2-2.6.0 → assertpy2-2.7.0}/.gitattributes +0 -0
  16. {assertpy2-2.6.0 → assertpy2-2.7.0}/.github/dependabot.yml +0 -0
  17. {assertpy2-2.6.0 → assertpy2-2.7.0}/.github/workflows/ci.yml +0 -0
  18. {assertpy2-2.6.0 → assertpy2-2.7.0}/.github/workflows/codeql.yml +0 -0
  19. {assertpy2-2.6.0 → assertpy2-2.7.0}/.github/workflows/docs.yml +0 -0
  20. {assertpy2-2.6.0 → assertpy2-2.7.0}/.github/workflows/publish.yml +0 -0
  21. {assertpy2-2.6.0 → assertpy2-2.7.0}/.github/workflows/scorecard.yml +0 -0
  22. {assertpy2-2.6.0 → assertpy2-2.7.0}/.github/workflows/zizmor.yml +0 -0
  23. {assertpy2-2.6.0 → assertpy2-2.7.0}/CONTRIBUTING.md +0 -0
  24. {assertpy2-2.6.0 → assertpy2-2.7.0}/LICENSE +0 -0
  25. {assertpy2-2.6.0 → assertpy2-2.7.0}/README.md +0 -0
  26. {assertpy2-2.6.0 → assertpy2-2.7.0}/SECURITY.md +0 -0
  27. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/__init__.py +0 -0
  28. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/_compat.py +0 -0
  29. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/async_assertions.py +0 -0
  30. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/base.py +0 -0
  31. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/behave_matchers.py +0 -0
  32. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/bytes_mixin.py +0 -0
  33. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/collection.py +0 -0
  34. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/contains.py +0 -0
  35. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/date.py +0 -0
  36. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/dict.py +0 -0
  37. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/dynamic.py +0 -0
  38. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/errors.py +0 -0
  39. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/extracting.py +0 -0
  40. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/file.py +0 -0
  41. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/helpers.py +0 -0
  42. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/json_mixin.py +0 -0
  43. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/matchers.py +0 -0
  44. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/numeric.py +0 -0
  45. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/py.typed +0 -0
  46. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/pytest_plugin.py +0 -0
  47. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/snapshot.py +0 -0
  48. {assertpy2-2.6.0 → assertpy2-2.7.0}/assertpy2/string.py +0 -0
  49. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/assertions.md +0 -0
  50. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/comparison.md +0 -0
  51. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/data.md +0 -0
  52. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/extending.md +0 -0
  53. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/fluent.md +0 -0
  54. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/getting-started.md +0 -0
  55. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/index.md +0 -0
  56. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/integrations.md +0 -0
  57. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/logo-dark.svg +0 -0
  58. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/logo.svg +0 -0
  59. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/matchers.md +0 -0
  60. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/migration.md +0 -0
  61. {assertpy2-2.6.0 → assertpy2-2.7.0}/docs/testing.md +0 -0
  62. {assertpy2-2.6.0 → assertpy2-2.7.0}/mkdocs.yml +0 -0
  63. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_async.py +0 -0
  64. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_behave_matchers.py +0 -0
  65. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_bool.py +0 -0
  66. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_bytes.py +0 -0
  67. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_class.py +0 -0
  68. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_collection.py +0 -0
  69. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_core.py +0 -0
  70. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_custom_dict.py +0 -0
  71. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_custom_list.py +0 -0
  72. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_datetime.py +0 -0
  73. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_description.py +0 -0
  74. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_dict.py +0 -0
  75. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_dict_compare.py +0 -0
  76. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_dyn.py +0 -0
  77. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_equals.py +0 -0
  78. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_errors.py +0 -0
  79. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_extensions.py +0 -0
  80. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_extracting.py +0 -0
  81. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_fail.py +0 -0
  82. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_file.py +0 -0
  83. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_grouped_soft.py +0 -0
  84. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_in.py +0 -0
  85. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_json.py +0 -0
  86. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_list.py +0 -0
  87. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_matcher_registry.py +0 -0
  88. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_matchers.py +0 -0
  89. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_matchers_phase3.py +0 -0
  90. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_namedtuple.py +0 -0
  91. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_none.py +0 -0
  92. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_not.py +0 -0
  93. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_numbers.py +0 -0
  94. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_overloads.py +0 -0
  95. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_phase2.py +0 -0
  96. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_pipeline.py +0 -0
  97. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_pytest_plugin.py +0 -0
  98. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_readme.py +0 -0
  99. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_recursive_compare.py +0 -0
  100. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_recursive_compare_attrs.py +0 -0
  101. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_recursive_compare_pydantic.py +0 -0
  102. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_regex_groups.py +0 -0
  103. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_rich_diff.py +0 -0
  104. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_same_as.py +0 -0
  105. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_snapshots.py +0 -0
  106. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_soft.py +0 -0
  107. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_soft_fail.py +0 -0
  108. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_string.py +0 -0
  109. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_structural.py +0 -0
  110. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_traceback.py +0 -0
  111. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_type.py +0 -0
  112. {assertpy2-2.6.0 → assertpy2-2.7.0}/tests/test_warn.py +0 -0
@@ -10,6 +10,7 @@ __pycache__
10
10
  cover
11
11
  .coverage
12
12
  htmlcov
13
+ .hypothesis
13
14
  __snapshots
14
15
  mycustompath
15
16
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.6.0
3
+ Version: 2.7.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
@@ -17,6 +17,7 @@ class _MixinBase:
17
17
  logger: logging.LoggerAdapter
18
18
  _not_expected: bool
19
19
  _expected_warning: type[Warning] | None
20
+ _return_value: object
20
21
 
21
22
  def error(
22
23
  self,
@@ -32,7 +33,7 @@ class _MixinBase:
32
33
  val: object,
33
34
  description: str = ...,
34
35
  kind: str | None = ...,
35
- expected: BaseException | None = ...,
36
+ expected: type[BaseException] | None = ...,
36
37
  logger: logging.LoggerAdapter | None = ...,
37
38
  ) -> Self: ...
38
39
 
@@ -215,6 +215,17 @@ if TYPE_CHECKING:
215
215
  def is_hex_equal_to(self, expected_hex: str) -> Self: ...
216
216
  def decoded_as(self, encoding: str = ...) -> _StringAssertion: ...
217
217
 
218
+ class _InvokedAssertion(_StringAssertion, Protocol):
219
+ """Assertions available after ``when_called_with()`` captured an exception/warning message.
220
+
221
+ The captured message is a ``str`` (hence the string assertions); :meth:`returned` pivots to
222
+ the value the callable returned. Its static type is unknown, so ``returned()`` exposes the
223
+ type-agnostic core assertions (``is_equal_to``, ``is_instance_of``, ``satisfies``, ...) -
224
+ type-safe by construction, never advertising methods that may not apply.
225
+ """
226
+
227
+ def returned(self) -> _CoreAssertion: ...
228
+
218
229
  class _CallableAssertion(_CoreAssertion, Protocol):
219
230
  """Assertions available for callable values."""
220
231
 
@@ -222,5 +233,6 @@ if TYPE_CHECKING:
222
233
  def does_not_raise(self, ex: type) -> Self: ...
223
234
  def warns(self, warning: type[Warning] = ...) -> Self: ...
224
235
  def does_not_warn(self, warning: type[Warning] = ...) -> Self: ...
225
- def when_called_with(self, *some_args: object, **some_kwargs: object) -> Self: ...
236
+ # captured value (exception or warning message) is a str; returned() pivots to the call result
237
+ def when_called_with(self, *some_args: object, **some_kwargs: object) -> _InvokedAssertion: ...
226
238
  def eventually(self, *, timeout: float = ..., interval: float = ...) -> AsyncAssertionBuilder: ...
@@ -37,7 +37,7 @@ from .date import DateMixin
37
37
  from .dict import DictMixin
38
38
  from .dynamic import DynamicMixin
39
39
  from .errors import AssertionFailure
40
- from .exception import ExceptionMixin
40
+ from .exception import _UNSET, ExceptionMixin
41
41
  from .extracting import ExtractingMixin
42
42
  from .file import FileMixin
43
43
  from .helpers import HelpersMixin
@@ -47,7 +47,7 @@ from .snapshot import SnapshotMixin
47
47
  from .string import StringMixin
48
48
  from .warning import WarningMixin
49
49
 
50
- __version__ = "2.6.0"
50
+ __version__ = "2.7.0"
51
51
 
52
52
  __tracebackhide__ = True # clean tracebacks via py.test integration
53
53
  contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
@@ -589,6 +589,7 @@ class AssertionBuilder(
589
589
  self.logger = logger if logger else _default_logger
590
590
  self._not_expected = False
591
591
  self._expected_warning = None
592
+ self._return_value = _UNSET
592
593
 
593
594
  @property
594
595
  def not_(self) -> NegatedBuilder:
@@ -9,6 +9,8 @@ if TYPE_CHECKING:
9
9
 
10
10
  __tracebackhide__ = True
11
11
 
12
+ _UNSET = object() # sentinel: no return value captured yet
13
+
12
14
 
13
15
  class _InertBuilder:
14
16
  """No-op builder returned after a failed raises/when_called_with in soft mode.
@@ -105,10 +107,34 @@ class ExceptionMixin(_MixinBase):
105
107
  )
106
108
  return cast("Self", _InertBuilder())
107
109
 
110
+ def returned(self) -> Self:
111
+ """Pivots the chain to the value ``val()`` returned during :meth:`when_called_with`.
112
+
113
+ Use after a call that completed normally (:meth:`~assertpy2.warning.WarningMixin.warns`,
114
+ :meth:`~assertpy2.warning.WarningMixin.does_not_warn`, or :meth:`does_not_raise`) to assert
115
+ on the return value in the same chain.
116
+
117
+ Examples:
118
+ Usage::
119
+
120
+ assert_that(make_client).warns(DeprecationWarning).when_called_with().returned().is_instance_of(Client)
121
+ assert_that(adder).does_not_raise(TypeError).when_called_with(1, 2).returned().is_equal_to(3)
122
+
123
+ Returns:
124
+ AssertionBuilder: a new instance wrapping the captured return value
125
+
126
+ Raises:
127
+ TypeError: if no return value was captured (the call raised, or :meth:`when_called_with`
128
+ was not invoked first)
129
+ """
130
+ if self._return_value is _UNSET:
131
+ raise TypeError("no return value captured; returned() is only valid after a call that completed normally")
132
+ return self.builder(self._return_value, self.description, self.kind, logger=self.logger)
133
+
108
134
  def _when_called_with_not_expected(self, *some_args, **some_kwargs) -> Self:
109
135
  assert self.expected is not None
110
136
  try:
111
- self.val(*some_args, **some_kwargs)
137
+ result = self.val(*some_args, **some_kwargs)
112
138
  except BaseException as e:
113
139
  if issubclass(type(e), self.expected):
114
140
  self.error(
@@ -117,6 +143,8 @@ class ExceptionMixin(_MixinBase):
117
143
  f" but did raise <{type(e).__name__}>."
118
144
  )
119
145
  return cast("Self", _InertBuilder())
146
+ return self
147
+ self._return_value = result
120
148
  return self
121
149
 
122
150
  def does_not_raise(self, ex) -> Self:
@@ -89,10 +89,12 @@ class WarningMixin(_MixinBase):
89
89
  def _when_called_with_warning(self, expected: type[Warning], *some_args, **some_kwargs) -> Self:
90
90
  with warnings.catch_warnings(record=True) as caught:
91
91
  warnings.simplefilter("always") # bypass __warningregistry__ "show once" dedup and filterwarnings=error
92
- self.val(*some_args, **some_kwargs)
92
+ result = self.val(*some_args, **some_kwargs)
93
93
  matched = [w for w in caught if issubclass(w.category, expected)]
94
94
  if matched:
95
- return self.builder(str(matched[0].message), self.description, self.kind, logger=self.logger)
95
+ captured = self.builder(str(matched[0].message), self.description, self.kind, logger=self.logger)
96
+ captured._return_value = result
97
+ return captured
96
98
  if caught:
97
99
  seen = ", ".join(sorted({w.category.__name__ for w in caught}))
98
100
  self.error(
@@ -110,7 +112,7 @@ class WarningMixin(_MixinBase):
110
112
  def _when_called_with_not_warning(self, expected: type[Warning], *some_args, **some_kwargs) -> Self:
111
113
  with warnings.catch_warnings(record=True) as caught:
112
114
  warnings.simplefilter("always") # bypass __warningregistry__ "show once" dedup and filterwarnings=error
113
- self.val(*some_args, **some_kwargs)
115
+ result = self.val(*some_args, **some_kwargs)
114
116
  matched = [w for w in caught if issubclass(w.category, expected)]
115
117
  if matched:
116
118
  seen = ", ".join(sorted({w.category.__name__ for w in matched}))
@@ -120,4 +122,5 @@ class WarningMixin(_MixinBase):
120
122
  f" but did warn <{seen}>."
121
123
  )
122
124
  return cast("Self", _InertBuilder())
125
+ self._return_value = result
123
126
  return self
@@ -119,6 +119,17 @@ The category defaults to `Warning` (matches any warning) and matches subclasses.
119
119
  assert_that(safe_func).does_not_warn(DeprecationWarning).when_called_with("foo")
120
120
  ```
121
121
 
122
+ To also assert on the value the call returned (alongside the warning, or after `does_not_warn` /
123
+ `does_not_raise`), pivot with `returned()`:
124
+
125
+ ```python
126
+ assert_that(make_client).warns(DeprecationWarning).when_called_with().returned().is_instance_of(Client)
127
+ assert_that(adder).does_not_raise(TypeError).when_called_with(1, 2).returned().is_equal_to(3)
128
+ ```
129
+
130
+ `returned()` exposes the type-agnostic core assertions (`is_equal_to`, `is_instance_of`, `satisfies`,
131
+ ...); it raises `TypeError` if the call raised (there is no return value to inspect).
132
+
122
133
  !!! warning "Not thread-safe"
123
134
  `warns()` / `does_not_warn()` rely on `warnings.catch_warnings()`, which mutates process-global
124
135
  state. They are safe within a single thread (including multiple `asyncio` tasks on one event
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "assertpy2"
3
- version = "2.6.0"
3
+ version = "2.7.0"
4
4
  description = "Fluent assertion library for Python with composable matchers, structural matching, and full type safety"
5
5
  readme = "README.md"
6
6
  license = "BSD-3-Clause"
@@ -53,6 +53,7 @@ build-backend = "hatchling.build"
53
53
 
54
54
  [dependency-groups]
55
55
  dev = [
56
+ "hypothesis>=6.155.6",
56
57
  "pytest>=9.1.1",
57
58
  "pytest-cov>=6.1",
58
59
  "ruff>=0.15.18",
@@ -161,3 +161,17 @@ def test_expected_exception_warn_wrong_type_preserves_logger():
161
161
  class Foo:
162
162
  def bar(self):
163
163
  raise RuntimeError("method err")
164
+
165
+
166
+ def safe_add(value):
167
+ return value + 1
168
+
169
+
170
+ def test_does_not_raise_returned_pivots_to_return_value():
171
+ assert_that(safe_add).does_not_raise(ValueError).when_called_with(41).returned().is_equal_to(42)
172
+
173
+
174
+ def test_returned_without_return_value_fails():
175
+ with pytest.raises(TypeError) as exc_info:
176
+ assert_that(func_no_arg).raises(RuntimeError).when_called_with().returned()
177
+ assert_that(str(exc_info.value)).contains("no return value captured")
@@ -175,3 +175,29 @@ def test_does_not_warn_failure_in_warn_mode_logs():
175
175
  assert_that(out).contains(
176
176
  "Expected <warn_user> to not warn <UserWarning> when called with (), but did warn <UserWarning>."
177
177
  )
178
+
179
+
180
+ # returned() pivot to the callable's return value
181
+
182
+
183
+ def warn_and_return_list(*args, **kwargs):
184
+ warnings.warn("dep since 2.7", DeprecationWarning, stacklevel=2)
185
+ return [1, 2, 3]
186
+
187
+
188
+ def quiet_return_ok(*args, **kwargs):
189
+ return "ok"
190
+
191
+
192
+ def test_warns_returned_pivots_to_return_value():
193
+ assert_that(warn_and_return_list).warns(DeprecationWarning).when_called_with().returned().is_equal_to([1, 2, 3])
194
+
195
+
196
+ def test_warns_returned_after_message_assertion():
197
+ assert_that(warn_and_return_list).warns(DeprecationWarning).when_called_with().matches(
198
+ "since 2.7"
199
+ ).returned().is_equal_to([1, 2, 3])
200
+
201
+
202
+ def test_does_not_warn_returned_pivots_to_return_value():
203
+ assert_that(quiet_return_ok).does_not_warn(DeprecationWarning).when_called_with().returned().is_equal_to("ok")
@@ -0,0 +1,333 @@
1
+ """Property-based tests (Hypothesis) for the riskiest pure-logic surfaces.
2
+
3
+ These complement the example-based suite: 100% line coverage does not exercise the *space* of
4
+ values, which is where recursive equality, ignore/include filtering, and matcher algebra hide bugs.
5
+ Each test states an invariant and lets Hypothesis attack it with generated data; on failure the
6
+ shrunk counterexample plus assertpy2's structured ``AssertionFailure`` pinpoint the mismatch.
7
+ """
8
+
9
+ import copy
10
+ from collections import namedtuple
11
+ from dataclasses import dataclass, replace
12
+
13
+ import pytest
14
+ from hypothesis import given, settings
15
+ from hypothesis import strategies as st
16
+
17
+ from assertpy2 import assert_that, match
18
+
19
+ # JSON-like values: atoms plus nested lists/dicts. NaN is excluded so equality stays reflexive.
20
+ _atoms = st.none() | st.booleans() | st.integers() | st.floats(allow_nan=False) | st.text()
21
+ _values = st.recursive(
22
+ _atoms,
23
+ lambda children: st.lists(children) | st.dictionaries(st.text(), children),
24
+ max_leaves=20,
25
+ )
26
+ _keys = st.text(min_size=1, max_size=5)
27
+
28
+
29
+ # --- is_equal_to / is_not_equal_to mirror Python equality ---
30
+
31
+
32
+ @settings(deadline=None)
33
+ @given(value=_values)
34
+ def test_is_equal_to_is_reflexive(value):
35
+ assert_that(value).is_equal_to(copy.deepcopy(value))
36
+
37
+
38
+ @settings(deadline=None)
39
+ @given(left=_values, right=_values)
40
+ def test_is_equal_to_consistent_with_eq(left, right):
41
+ if left == right:
42
+ assert_that(left).is_equal_to(right)
43
+ else:
44
+ with pytest.raises(AssertionError):
45
+ assert_that(left).is_equal_to(right)
46
+
47
+
48
+ @settings(deadline=None)
49
+ @given(left=_values, right=_values)
50
+ def test_is_not_equal_to_is_the_inverse(left, right):
51
+ if left != right:
52
+ assert_that(left).is_not_equal_to(right)
53
+ else:
54
+ with pytest.raises(AssertionError):
55
+ assert_that(left).is_not_equal_to(right)
56
+
57
+
58
+ @given(left=st.sets(st.integers()), right=st.sets(st.integers()))
59
+ def test_is_equal_to_consistent_with_eq_for_sets(left, right):
60
+ if left == right:
61
+ assert_that(left).is_equal_to(right)
62
+ else:
63
+ with pytest.raises(AssertionError):
64
+ assert_that(left).is_equal_to(right)
65
+
66
+
67
+ # --- ignore / include selective comparison ---
68
+
69
+
70
+ @settings(deadline=None)
71
+ @given(base=st.dictionaries(_keys, _atoms, max_size=6), perturbations=st.dictionaries(_keys, _atoms, max_size=4))
72
+ def test_ignore_removes_differences(base, perturbations):
73
+ # left keeps base values; right overlays perturbations. Ignoring exactly the perturbed keys
74
+ # leaves both dicts identical on the remaining (base) keys, so they compare equal.
75
+ left = dict(base)
76
+ right = {**base, **perturbations}
77
+ assert_that(left).is_equal_to(right, ignore=list(perturbations))
78
+
79
+
80
+ @settings(deadline=None)
81
+ @given(
82
+ common=st.dictionaries(_keys, _atoms, min_size=1, max_size=4),
83
+ left_extra=st.dictionaries(_keys, _atoms, max_size=3),
84
+ right_extra=st.dictionaries(_keys, _atoms, max_size=3),
85
+ )
86
+ def test_include_compares_only_listed_keys(common, left_extra, right_extra):
87
+ # both dicts agree on every common key; extras (outside the common set) differ but are excluded.
88
+ included = list(common)
89
+ left = {**{k: v for k, v in left_extra.items() if k not in common}, **common}
90
+ right = {**{k: v for k, v in right_extra.items() if k not in common}, **common}
91
+ assert_that(left).is_equal_to(right, include=included)
92
+
93
+
94
+ # --- matcher predicates mirror their Python semantics ---
95
+
96
+
97
+ @given(value=st.integers(), low=st.integers(), high=st.integers())
98
+ def test_between_matcher_matches_python_semantics(value, low, high):
99
+ assert match.between(low, high).matches(value) == (low <= value <= high)
100
+
101
+
102
+ @given(value=st.integers(), boundary=st.integers())
103
+ def test_greater_than_matcher_matches_python_semantics(value, boundary):
104
+ assert match.greater_than(boundary).matches(value) == (value > boundary)
105
+
106
+
107
+ @given(value=st.integers(), low=st.integers(), high=st.integers())
108
+ def test_satisfies_between_consistent_with_semantics(value, low, high):
109
+ if low <= value <= high:
110
+ assert_that(value).satisfies(match.between(low, high))
111
+ else:
112
+ with pytest.raises(AssertionError):
113
+ assert_that(value).satisfies(match.between(low, high))
114
+
115
+
116
+ # --- matcher boolean algebra (& | ~) ---
117
+
118
+
119
+ @given(value=st.integers(), boundary_low=st.integers(), boundary_high=st.integers())
120
+ def test_matcher_combinators_follow_boolean_logic(value, boundary_low, boundary_high):
121
+ matcher_a = match.greater_than(boundary_low)
122
+ matcher_b = match.less_than(boundary_high)
123
+ result_a = matcher_a.matches(value)
124
+ result_b = matcher_b.matches(value)
125
+
126
+ assert (matcher_a & matcher_b).matches(value) == (result_a and result_b)
127
+ assert (matcher_a | matcher_b).matches(value) == (result_a or result_b)
128
+ assert (~matcher_a).matches(value) == (not result_a)
129
+ # de Morgan: ~(a & b) == (~a | ~b)
130
+ assert (~(matcher_a & matcher_b)).matches(value) == ((~matcher_a) | (~matcher_b)).matches(value)
131
+
132
+
133
+ # --- collection roundtrips ---
134
+
135
+
136
+ @given(items=st.lists(st.integers()))
137
+ def test_is_length_matches_len(items):
138
+ assert_that(items).is_length(len(items))
139
+
140
+
141
+ @given(items=st.lists(st.integers(), min_size=1))
142
+ def test_contains_every_element(items):
143
+ assert_that(items).contains(*items)
144
+
145
+
146
+ @given(items=st.lists(st.integers()), candidate=st.integers())
147
+ def test_contains_present_iff_member(items, candidate):
148
+ if candidate in items:
149
+ assert_that(items).contains(candidate)
150
+ else:
151
+ with pytest.raises(AssertionError):
152
+ assert_that(items).contains(candidate)
153
+
154
+
155
+ # === Point 1: recursive comparison over dataclasses / namedtuples ===
156
+ # Exercises the riskiest code: _build_equality_diff / _sub_diff_entries (nesting, models, lists).
157
+
158
+
159
+ @dataclass
160
+ class _Inner:
161
+ a: int
162
+ b: str
163
+
164
+
165
+ @dataclass
166
+ class _Outer:
167
+ inner: _Inner
168
+ items: list[int]
169
+ name: str
170
+
171
+
172
+ _Pair = namedtuple("_Pair", ["first", "second"])
173
+
174
+ _inners = st.builds(_Inner, a=st.integers(), b=st.text(max_size=5))
175
+ _outers = st.builds(_Outer, inner=_inners, items=st.lists(st.integers(), max_size=4), name=st.text(max_size=5))
176
+ _pairs = st.builds(_Pair, first=st.integers(), second=st.text(max_size=5))
177
+
178
+
179
+ @settings(deadline=None)
180
+ @given(left=_outers, right=_outers)
181
+ def test_dataclass_is_equal_to_consistent_with_eq(left, right):
182
+ if left == right:
183
+ assert_that(left).is_equal_to(right)
184
+ else:
185
+ with pytest.raises(AssertionError):
186
+ assert_that(left).is_equal_to(right)
187
+
188
+
189
+ @settings(deadline=None)
190
+ @given(value=_outers)
191
+ def test_dataclass_is_equal_to_reflexive(value):
192
+ assert_that(value).is_equal_to(copy.deepcopy(value))
193
+
194
+
195
+ @settings(deadline=None)
196
+ @given(value=_outers, new_name=st.text(max_size=5))
197
+ def test_dataclass_ignore_removes_field_difference(value, new_name):
198
+ other = replace(value, name=new_name)
199
+ assert_that(value).is_equal_to(other, ignore="name")
200
+
201
+
202
+ @given(left=_pairs, right=_pairs)
203
+ def test_namedtuple_is_equal_to_consistent_with_eq(left, right):
204
+ if left == right:
205
+ assert_that(left).is_equal_to(right)
206
+ else:
207
+ with pytest.raises(AssertionError):
208
+ assert_that(left).is_equal_to(right)
209
+
210
+
211
+ @given(value=_pairs)
212
+ def test_namedtuple_is_equal_to_reflexive(value):
213
+ assert_that(value).is_equal_to(copy.deepcopy(value))
214
+
215
+
216
+ # === Point 2: multiset / ordering semantics of collection assertions ===
217
+
218
+
219
+ def _is_subsequence(sub, seq):
220
+ # shared iterator advances across the any() calls - the classic subsequence check
221
+ iterator = iter(seq)
222
+ return all(any(candidate == element for candidate in iterator) for element in sub)
223
+
224
+
225
+ @given(val=st.lists(st.integers(), max_size=6), expected=st.lists(st.integers(), min_size=1, max_size=6))
226
+ def test_contains_exactly_matches_list_equality(val, expected):
227
+ if val == expected:
228
+ assert_that(val).contains_exactly(*expected)
229
+ else:
230
+ with pytest.raises(AssertionError):
231
+ assert_that(val).contains_exactly(*expected)
232
+
233
+
234
+ @given(val=st.lists(st.integers(), max_size=6), items=st.lists(st.integers(), min_size=1, max_size=6))
235
+ def test_contains_only_matches_set_equality(val, items):
236
+ if set(val) == set(items):
237
+ assert_that(val).contains_only(*items)
238
+ else:
239
+ with pytest.raises(AssertionError):
240
+ assert_that(val).contains_only(*items)
241
+
242
+
243
+ @given(val=st.lists(st.integers(), max_size=8), items=st.lists(st.integers(), min_size=1, max_size=4))
244
+ def test_contains_sequence_matches_contiguous_slice(val, items):
245
+ window = len(items)
246
+ is_contiguous = any(val[index : index + window] == items for index in range(len(val) - window + 1))
247
+ if is_contiguous:
248
+ assert_that(val).contains_sequence(*items)
249
+ else:
250
+ with pytest.raises(AssertionError):
251
+ assert_that(val).contains_sequence(*items)
252
+
253
+
254
+ @given(val=st.lists(st.integers(), max_size=8), items=st.lists(st.integers(), min_size=1, max_size=4))
255
+ def test_contains_in_order_matches_subsequence(val, items):
256
+ if _is_subsequence(items, val):
257
+ assert_that(val).contains_in_order(*items)
258
+ else:
259
+ with pytest.raises(AssertionError):
260
+ assert_that(val).contains_in_order(*items)
261
+
262
+
263
+ @given(items=st.lists(st.integers(), max_size=10))
264
+ def test_does_not_contain_duplicates_iff_unique(items):
265
+ if len(items) == len(set(items)):
266
+ assert_that(items).does_not_contain_duplicates()
267
+ else:
268
+ with pytest.raises(AssertionError):
269
+ assert_that(items).does_not_contain_duplicates()
270
+
271
+
272
+ @given(items=st.lists(st.integers(), max_size=10))
273
+ def test_is_sorted_matches_python_sorted(items):
274
+ if items == sorted(items):
275
+ assert_that(items).is_sorted()
276
+ else:
277
+ with pytest.raises(AssertionError):
278
+ assert_that(items).is_sorted()
279
+
280
+
281
+ @given(items=st.lists(st.integers(), max_size=10))
282
+ def test_is_sorted_reverse_matches_python_sorted(items):
283
+ if items == sorted(items, reverse=True):
284
+ assert_that(items).is_sorted(reverse=True)
285
+ else:
286
+ with pytest.raises(AssertionError):
287
+ assert_that(items).is_sorted(reverse=True)
288
+
289
+
290
+ @given(items=st.lists(st.integers(), max_size=8), superset=st.lists(st.integers(), max_size=10))
291
+ def test_is_subset_of_matches_set_subset(items, superset):
292
+ if set(items) <= set(superset):
293
+ assert_that(items).is_subset_of(superset)
294
+ else:
295
+ with pytest.raises(AssertionError):
296
+ assert_that(items).is_subset_of(superset)
297
+
298
+
299
+ # === Point 3: nested ignore via tuple key-paths (recursion in _dict_not_equal) ===
300
+
301
+ _two_level = st.dictionaries(
302
+ st.text(min_size=1, max_size=3),
303
+ st.dictionaries(st.text(min_size=1, max_size=3), st.integers(), max_size=3),
304
+ max_size=3,
305
+ )
306
+
307
+
308
+ @settings(deadline=None)
309
+ @given(base=_two_level, data=st.data())
310
+ def test_nested_ignore_removes_leaf_differences(base, data):
311
+ paths = [(outer, leaf) for outer, inner in base.items() for leaf in inner]
312
+ perturbed = data.draw(st.lists(st.sampled_from(paths), unique=True)) if paths else []
313
+ right = {outer: dict(inner) for outer, inner in base.items()}
314
+ for outer, leaf in perturbed:
315
+ right[outer][leaf] += 1000 # ints, so always a real difference
316
+ # ignoring exactly the perturbed leaf-paths leaves both dicts identical elsewhere
317
+ assert_that(base).is_equal_to(right, ignore=list(perturbed))
318
+
319
+
320
+ @settings(deadline=None)
321
+ @given(base=_two_level, data=st.data())
322
+ def test_nested_include_compares_only_listed_leaves(base, data):
323
+ paths = [(outer, leaf) for outer, inner in base.items() for leaf in inner]
324
+ if not paths:
325
+ return # include needs at least one referenced path; nothing to compare on an empty dict
326
+ included = data.draw(st.lists(st.sampled_from(paths), unique=True, min_size=1))
327
+ included_set = set(included)
328
+ right = {outer: dict(inner) for outer, inner in base.items()}
329
+ for outer, leaf in paths:
330
+ if (outer, leaf) not in included_set:
331
+ right[outer][leaf] += 1000 # perturb only NON-included leaves (keys untouched)
332
+ # comparing only the included leaf-paths -> equal, since those leaves are left intact
333
+ assert_that(base).is_equal_to(right, include=list(included))
@@ -34,7 +34,7 @@ wheels = [
34
34
 
35
35
  [[package]]
36
36
  name = "assertpy2"
37
- version = "2.6.0"
37
+ version = "2.7.0"
38
38
  source = { editable = "." }
39
39
  dependencies = [
40
40
  { name = "typing-extensions", marker = "python_full_version < '3.11'" },
@@ -54,6 +54,7 @@ json = [
54
54
 
55
55
  [package.dev-dependencies]
56
56
  dev = [
57
+ { name = "hypothesis" },
57
58
  { name = "pytest" },
58
59
  { name = "pytest-cov" },
59
60
  { name = "ruff" },
@@ -76,6 +77,7 @@ provides-extras = ["allure", "behave", "json"]
76
77
 
77
78
  [package.metadata.requires-dev]
78
79
  dev = [
80
+ { name = "hypothesis", specifier = ">=6.155.6" },
79
81
  { name = "pytest", specifier = ">=9.1.1" },
80
82
  { name = "pytest-cov", specifier = ">=6.1" },
81
83
  { name = "ruff", specifier = ">=0.15.18" },
@@ -428,6 +430,19 @@ wheels = [
428
430
  { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
429
431
  ]
430
432
 
433
+ [[package]]
434
+ name = "hypothesis"
435
+ version = "6.155.6"
436
+ source = { registry = "https://pypi.org/simple" }
437
+ dependencies = [
438
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
439
+ { name = "sortedcontainers" },
440
+ ]
441
+ sdist = { url = "https://files.pythonhosted.org/packages/10/aa/9a91a4addf285702a98713da44b3581799539426436617bfb8914478c166/hypothesis-6.155.6.tar.gz", hash = "sha256:7569e1897690336c85d49d8391b49ec6ab83d951009515bfc29faebbac286cf5", size = 478038, upload-time = "2026-06-19T13:21:23.379Z" }
442
+ wheels = [
443
+ { url = "https://files.pythonhosted.org/packages/9e/a9/4c17e962c2e9cbc314bb579ed2e2b2da45d7b6b942aab6948d14d85abfea/hypothesis-6.155.6-py3-none-any.whl", hash = "sha256:a96d9a29f6bbc8ccac39dd84e140892da76765464929f401a4181b90c20c9ad1", size = 544521, upload-time = "2026-06-19T13:21:20.934Z" },
444
+ ]
445
+
431
446
  [[package]]
432
447
  name = "idna"
433
448
  version = "3.18"
@@ -1205,6 +1220,15 @@ wheels = [
1205
1220
  { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
1206
1221
  ]
1207
1222
 
1223
+ [[package]]
1224
+ name = "sortedcontainers"
1225
+ version = "2.4.0"
1226
+ source = { registry = "https://pypi.org/simple" }
1227
+ sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
1228
+ wheels = [
1229
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
1230
+ ]
1231
+
1208
1232
  [[package]]
1209
1233
  name = "tomli"
1210
1234
  version = "2.4.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes