assertpy2 2.3.3__tar.gz → 2.3.5__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 (82) hide show
  1. {assertpy2-2.3.3 → assertpy2-2.3.5}/PKG-INFO +27 -18
  2. {assertpy2-2.3.3 → assertpy2-2.3.5}/README.md +26 -17
  3. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/__init__.py +2 -1
  4. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/assertpy.py +1 -4
  5. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/collection.py +0 -2
  6. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/exception.py +0 -1
  7. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/helpers.py +0 -6
  8. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/matchers.py +57 -24
  9. {assertpy2-2.3.3 → assertpy2-2.3.5}/docs/api.md +32 -0
  10. {assertpy2-2.3.3 → assertpy2-2.3.5}/pyproject.toml +2 -2
  11. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_matcher_registry.py +20 -5
  12. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_matchers.py +104 -0
  13. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_structural.py +24 -0
  14. {assertpy2-2.3.3 → assertpy2-2.3.5}/uv.lock +1 -1
  15. {assertpy2-2.3.3 → assertpy2-2.3.5}/.codecov.yml +0 -0
  16. {assertpy2-2.3.3 → assertpy2-2.3.5}/.github/dependabot.yml +0 -0
  17. {assertpy2-2.3.3 → assertpy2-2.3.5}/.github/workflows/ci.yml +0 -0
  18. {assertpy2-2.3.3 → assertpy2-2.3.5}/.github/workflows/codeql.yml +0 -0
  19. {assertpy2-2.3.3 → assertpy2-2.3.5}/.github/workflows/publish.yml +0 -0
  20. {assertpy2-2.3.3 → assertpy2-2.3.5}/.github/workflows/scorecard.yml +0 -0
  21. {assertpy2-2.3.3 → assertpy2-2.3.5}/.gitignore +0 -0
  22. {assertpy2-2.3.3 → assertpy2-2.3.5}/CONTRIBUTING.md +0 -0
  23. {assertpy2-2.3.3 → assertpy2-2.3.5}/LICENSE +0 -0
  24. {assertpy2-2.3.3 → assertpy2-2.3.5}/SECURITY.md +0 -0
  25. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/_mixin_base.py +0 -0
  26. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/_typing.py +0 -0
  27. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/async_assertions.py +0 -0
  28. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/base.py +0 -0
  29. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/behave_matchers.py +0 -0
  30. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/contains.py +0 -0
  31. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/date.py +0 -0
  32. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/dict.py +0 -0
  33. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/dynamic.py +0 -0
  34. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/errors.py +0 -0
  35. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/extracting.py +0 -0
  36. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/file.py +0 -0
  37. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/numeric.py +0 -0
  38. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/py.typed +0 -0
  39. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/pytest_plugin.py +0 -0
  40. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/snapshot.py +0 -0
  41. {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/string.py +0 -0
  42. {assertpy2-2.3.3 → assertpy2-2.3.5}/docs/logo-dark.svg +0 -0
  43. {assertpy2-2.3.3 → assertpy2-2.3.5}/docs/logo.svg +0 -0
  44. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_async.py +0 -0
  45. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_behave_matchers.py +0 -0
  46. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_bool.py +0 -0
  47. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_class.py +0 -0
  48. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_collection.py +0 -0
  49. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_core.py +0 -0
  50. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_custom_dict.py +0 -0
  51. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_custom_list.py +0 -0
  52. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_datetime.py +0 -0
  53. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_description.py +0 -0
  54. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_dict.py +0 -0
  55. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_dict_compare.py +0 -0
  56. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_dyn.py +0 -0
  57. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_equals.py +0 -0
  58. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_errors.py +0 -0
  59. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_expected_exception.py +0 -0
  60. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_extensions.py +0 -0
  61. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_extracting.py +0 -0
  62. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_fail.py +0 -0
  63. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_file.py +0 -0
  64. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_in.py +0 -0
  65. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_list.py +0 -0
  66. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_matchers_phase3.py +0 -0
  67. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_namedtuple.py +0 -0
  68. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_none.py +0 -0
  69. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_numbers.py +0 -0
  70. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_overloads.py +0 -0
  71. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_phase2.py +0 -0
  72. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_pytest_plugin.py +0 -0
  73. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_readme.py +0 -0
  74. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_regex_groups.py +0 -0
  75. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_same_as.py +0 -0
  76. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_snapshots.py +0 -0
  77. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_soft.py +0 -0
  78. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_soft_fail.py +0 -0
  79. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_string.py +0 -0
  80. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_traceback.py +0 -0
  81. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_type.py +0 -0
  82. {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_warn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.3.3
3
+ Version: 2.3.5
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
@@ -160,25 +160,25 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
160
160
 
161
161
  ## Features
162
162
 
163
- - **Composable matchers**: `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~` operators.
164
- - **Structural matching**: `matches_structure()` for declarative dict/API response validation.
165
- - **Async assertions**: `eventually()` with polling/retry for async and eventual consistency testing.
166
- - **Structured errors**: `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
163
+ - [**Composable matchers**](docs/api.md#composable-matchers): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
164
+ - [**Structural matching**](docs/api.md#structural-matching): `matches_structure()` for declarative dict/API response validation.
165
+ - [**Async assertions**](docs/api.md#async-assertions): `eventually()` with polling/retry for async and eventual consistency testing.
166
+ - [**Structured errors**](docs/api.md#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
167
167
  - **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
168
168
  - **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
169
- - **Soft assertions**: thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
170
- - **Fluent chaining**: write assertions as readable one-liners that chain naturally.
171
- - **Dynamic assertions**: `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
172
- - **Dict comparison**: `is_equal_to()` with `ignore` and `include` for selective key matching.
173
- - **Extracting**: flatten collections on attributes with `filter` and `sort` support.
174
- - **File assertions**: `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
175
- - **Snapshot testing**: store and compare data structures in JSON format, inspired by Jest.
176
- - **Allure integration**: auto-attach structured diff and actual/expected data to Allure reports.
177
- - **Behave step matchers**: ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
178
- - **Custom matchers**: register domain-specific matchers via `register_matcher()`, composable with `&`, `|`, `~`.
179
- - **Regex group extraction**: `extracting_group()` and `matches_with_groups()` to assert on regex captures fluently.
180
- - **Extensions**: add custom assertions via `add_extension()`.
181
- - Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
169
+ - [**Soft assertions**](docs/api.md#soft-assertions): thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
170
+ - [**Fluent chaining**](docs/api.md#chaining): write assertions as readable one-liners that chain naturally.
171
+ - [**Dynamic assertions**](docs/api.md#objects): `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
172
+ - [**Dict comparison**](docs/api.md#dicts): `is_equal_to()` with `ignore` and `include` for selective key matching.
173
+ - [**Extracting**](docs/api.md#objects): flatten collections on attributes with `filter` and `sort` support.
174
+ - [**File assertions**](docs/api.md#files): `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
175
+ - [**Snapshot testing**](docs/api.md#snapshot-testing): store and compare data structures in JSON format, inspired by Jest.
176
+ - [**Allure integration**](docs/api.md#allure-integration): auto-attach structured diff and actual/expected data to Allure reports.
177
+ - [**Behave step matchers**](docs/api.md#behave-step-matchers): ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
178
+ - [**Custom matchers**](docs/api.md#custom-matchers---registering-domain-matchers): register domain-specific matchers via `register_matcher()`, composable with `&`, `|`, `~`.
179
+ - [**Regex group extraction**](docs/api.md#regex-group-extraction): `extracting_group()` and `matches_with_groups()` to assert on regex captures fluently.
180
+ - [**Extensions**](docs/api.md#extension-system---adding-custom-assertions): add custom assertions via `add_extension()`.
181
+ - [Strings](docs/api.md#strings), [numbers](docs/api.md#numbers), [lists](docs/api.md#lists), [tuples](docs/api.md#tuples), [sets](docs/api.md#sets), [dicts](docs/api.md#dicts), [dates](docs/api.md#dates), [booleans](docs/api.md#booleans), [objects](docs/api.md#objects), [exceptions](docs/api.md#failure).
182
182
 
183
183
 
184
184
  ## Composable matchers
@@ -204,6 +204,15 @@ assert_that("hello").satisfies(~match.equal_to("world"))
204
204
  assert_that(150).satisfies(match.is_negative() | match.greater_than(100))
205
205
  ```
206
206
 
207
+ Matchers also support `==` directly, so you can use them with plain `assert` or mix into dicts and lists:
208
+
209
+ ```py
210
+ from assertpy2 import match
211
+
212
+ assert 42 == match.is_positive()
213
+ assert {"id": 5, "name": "Alice"} == {"id": match.is_positive(), "name": match.is_non_empty_string()}
214
+ ```
215
+
207
216
  Available matchers: `equal_to`, `greater_than`, `greater_than_or_equal_to`, `less_than`, `less_than_or_equal_to`, `between`, `close_to`, `is_none`, `is_not_none`, `is_instance_of`, `has_length`, `is_empty`, `is_not_empty`, `is_positive`, `is_negative`, `is_zero`, `is_even`, `is_odd`, `is_divisible_by`, `is_callable`, `is_in`, `has_property`, `contains_string`, `matches_regex`, `is_uuid`, `is_non_empty_string`, `ignore`, `each_item`, `structure`.
208
217
 
209
218
 
@@ -125,25 +125,25 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
125
125
 
126
126
  ## Features
127
127
 
128
- - **Composable matchers**: `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~` operators.
129
- - **Structural matching**: `matches_structure()` for declarative dict/API response validation.
130
- - **Async assertions**: `eventually()` with polling/retry for async and eventual consistency testing.
131
- - **Structured errors**: `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
128
+ - [**Composable matchers**](docs/api.md#composable-matchers): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
129
+ - [**Structural matching**](docs/api.md#structural-matching): `matches_structure()` for declarative dict/API response validation.
130
+ - [**Async assertions**](docs/api.md#async-assertions): `eventually()` with polling/retry for async and eventual consistency testing.
131
+ - [**Structured errors**](docs/api.md#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
132
132
  - **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
133
133
  - **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
134
- - **Soft assertions**: thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
135
- - **Fluent chaining**: write assertions as readable one-liners that chain naturally.
136
- - **Dynamic assertions**: `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
137
- - **Dict comparison**: `is_equal_to()` with `ignore` and `include` for selective key matching.
138
- - **Extracting**: flatten collections on attributes with `filter` and `sort` support.
139
- - **File assertions**: `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
140
- - **Snapshot testing**: store and compare data structures in JSON format, inspired by Jest.
141
- - **Allure integration**: auto-attach structured diff and actual/expected data to Allure reports.
142
- - **Behave step matchers**: ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
143
- - **Custom matchers**: register domain-specific matchers via `register_matcher()`, composable with `&`, `|`, `~`.
144
- - **Regex group extraction**: `extracting_group()` and `matches_with_groups()` to assert on regex captures fluently.
145
- - **Extensions**: add custom assertions via `add_extension()`.
146
- - Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
134
+ - [**Soft assertions**](docs/api.md#soft-assertions): thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
135
+ - [**Fluent chaining**](docs/api.md#chaining): write assertions as readable one-liners that chain naturally.
136
+ - [**Dynamic assertions**](docs/api.md#objects): `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
137
+ - [**Dict comparison**](docs/api.md#dicts): `is_equal_to()` with `ignore` and `include` for selective key matching.
138
+ - [**Extracting**](docs/api.md#objects): flatten collections on attributes with `filter` and `sort` support.
139
+ - [**File assertions**](docs/api.md#files): `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
140
+ - [**Snapshot testing**](docs/api.md#snapshot-testing): store and compare data structures in JSON format, inspired by Jest.
141
+ - [**Allure integration**](docs/api.md#allure-integration): auto-attach structured diff and actual/expected data to Allure reports.
142
+ - [**Behave step matchers**](docs/api.md#behave-step-matchers): ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
143
+ - [**Custom matchers**](docs/api.md#custom-matchers---registering-domain-matchers): register domain-specific matchers via `register_matcher()`, composable with `&`, `|`, `~`.
144
+ - [**Regex group extraction**](docs/api.md#regex-group-extraction): `extracting_group()` and `matches_with_groups()` to assert on regex captures fluently.
145
+ - [**Extensions**](docs/api.md#extension-system---adding-custom-assertions): add custom assertions via `add_extension()`.
146
+ - [Strings](docs/api.md#strings), [numbers](docs/api.md#numbers), [lists](docs/api.md#lists), [tuples](docs/api.md#tuples), [sets](docs/api.md#sets), [dicts](docs/api.md#dicts), [dates](docs/api.md#dates), [booleans](docs/api.md#booleans), [objects](docs/api.md#objects), [exceptions](docs/api.md#failure).
147
147
 
148
148
 
149
149
  ## Composable matchers
@@ -169,6 +169,15 @@ assert_that("hello").satisfies(~match.equal_to("world"))
169
169
  assert_that(150).satisfies(match.is_negative() | match.greater_than(100))
170
170
  ```
171
171
 
172
+ Matchers also support `==` directly, so you can use them with plain `assert` or mix into dicts and lists:
173
+
174
+ ```py
175
+ from assertpy2 import match
176
+
177
+ assert 42 == match.is_positive()
178
+ assert {"id": 5, "name": "Alice"} == {"id": match.is_positive(), "name": match.is_non_empty_string()}
179
+ ```
180
+
172
181
  Available matchers: `equal_to`, `greater_than`, `greater_than_or_equal_to`, `less_than`, `less_than_or_equal_to`, `between`, `close_to`, `is_none`, `is_not_none`, `is_instance_of`, `has_length`, `is_empty`, `is_not_empty`, `is_positive`, `is_negative`, `is_zero`, `is_even`, `is_odd`, `is_divisible_by`, `is_callable`, `is_in`, `has_property`, `contains_string`, `matches_regex`, `is_uuid`, `is_non_empty_string`, `ignore`, `each_item`, `structure`.
173
182
 
174
183
 
@@ -12,7 +12,7 @@ from .assertpy import (
12
12
  from .async_assertions import AsyncAssertionBuilder
13
13
  from .errors import AssertionFailure, DiffEntry, DiffResult
14
14
  from .file import contents_of
15
- from .matchers import Matcher, match, register_matcher, unregister_matcher
15
+ from .matchers import Matcher, clear_custom_matchers, match, register_matcher, unregister_matcher
16
16
 
17
17
  __all__ = [
18
18
  "AssertionFailure",
@@ -25,6 +25,7 @@ __all__ = [
25
25
  "add_extension",
26
26
  "assert_that",
27
27
  "assert_warn",
28
+ "clear_custom_matchers",
28
29
  "contents_of",
29
30
  "fail",
30
31
  "match",
@@ -73,7 +73,7 @@ from .numeric import NumericMixin
73
73
  from .snapshot import SnapshotMixin
74
74
  from .string import StringMixin
75
75
 
76
- __version__ = "2.3.3"
76
+ __version__ = "2.3.5"
77
77
 
78
78
  __tracebackhide__ = True # clean tracebacks via py.test integration
79
79
  contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
@@ -397,7 +397,6 @@ def _builder(val, description="", kind=None, expected=None, logger=None):
397
397
  """Internal helper to build a new :class:`AssertionBuilder` instance and glue on any extension methods."""
398
398
  ab = AssertionBuilder(val, description, kind, expected, logger)
399
399
  if _extensions:
400
- # glue extension method onto new builder instance
401
400
  for name, func in _extensions.items():
402
401
  meth = types.MethodType(func, ab)
403
402
  setattr(ab, name, meth)
@@ -410,13 +409,11 @@ class WarningLoggingAdapter(logging.LoggerAdapter):
410
409
 
411
410
  def process(self, msg, kwargs):
412
411
  def _unwind(frame):
413
- # walk all the frames
414
412
  frames = []
415
413
  while frame:
416
414
  frames.append((frame.f_code.co_filename, frame.f_lineno))
417
415
  frame = frame.f_back
418
416
 
419
- # in reverse, find the first assertpy frame (and return the previous one)
420
417
  prev = None
421
418
  for frame in reversed(
422
419
  frames
@@ -118,7 +118,6 @@ class CollectionMixin(_MixinBase):
118
118
 
119
119
  missing = []
120
120
  if hasattr(self.val, "keys") and callable(self.val.keys) and hasattr(self.val, "__getitem__"):
121
- # flatten superset dicts
122
121
  superdict = {}
123
122
  for idx, j in enumerate(supersets):
124
123
  self._check_dict_like(j, check_values=False, name=f"arg #{idx + 1}")
@@ -136,7 +135,6 @@ class CollectionMixin(_MixinBase):
136
135
  f"but {self._fmt_items(missing)} {'was' if len(missing) == 1 else 'were'} missing."
137
136
  )
138
137
  else:
139
- # flatten supersets
140
138
  superset = set()
141
139
  for j in supersets:
142
140
  try:
@@ -73,7 +73,6 @@ class ExceptionMixin(_MixinBase):
73
73
  if not issubclass(ex, BaseException):
74
74
  raise TypeError("given arg must be exception")
75
75
 
76
- # chain on with ex as the expected exception
77
76
  return self.builder(self.val, self.description, self.kind, ex, self.logger)
78
77
 
79
78
  def when_called_with(self, *some_args, **some_kwargs) -> Self:
@@ -163,7 +163,6 @@ class HelpersMixin(_MixinBase):
163
163
  ignores = self._dict_ignore(ignore)
164
164
  includes = self._dict_include(include)
165
165
 
166
- # guarantee include keys are in val
167
166
  if include:
168
167
  missing = []
169
168
  for i in includes:
@@ -181,7 +180,6 @@ class HelpersMixin(_MixinBase):
181
180
  f" but did not include key{missing_suffix} {missing_fmt}."
182
181
  )
183
182
 
184
- # calc val keys given ignores and includes
185
183
  if ignore and include:
186
184
  k1 = {k for k in val if k not in ignores and k in includes}
187
185
  elif ignore:
@@ -189,7 +187,6 @@ class HelpersMixin(_MixinBase):
189
187
  else: # include
190
188
  k1 = {k for k in val if k in includes}
191
189
 
192
- # calc other keys given ignores and includes
193
190
  if ignore and include:
194
191
  k2 = {k for k in other if k not in ignores and k in includes}
195
192
  elif ignore:
@@ -198,7 +195,6 @@ class HelpersMixin(_MixinBase):
198
195
  k2 = {k for k in other if k in includes}
199
196
 
200
197
  if k1 != k2:
201
- # different set of keys, so not equal
202
198
  return True
203
199
  else:
204
200
  for k in k1:
@@ -214,10 +210,8 @@ class HelpersMixin(_MixinBase):
214
210
  else None,
215
211
  )
216
212
  if subdicts_not_equal:
217
- # fast fail inside the loop since sub-dicts are not equal
218
213
  return True
219
214
  elif val[k] != other[k]:
220
- # fast fail inside the loop since values are not equal
221
215
  return True
222
216
  return False
223
217
  else:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
+ import threading
4
5
  import uuid as _uuid_mod
5
6
  from collections.abc import Callable
6
7
  from typing import Any, Protocol, runtime_checkable
@@ -42,6 +43,12 @@ class BaseMatcher:
42
43
  def __invert__(self) -> NotMatcher:
43
44
  return NotMatcher(self)
44
45
 
46
+ def __eq__(self, other: object) -> bool:
47
+ return self.matches(other)
48
+
49
+ def __hash__(self) -> int:
50
+ return id(self)
51
+
45
52
  def __repr__(self) -> str:
46
53
  return self.describe()
47
54
 
@@ -117,7 +124,10 @@ class GreaterThanMatcher(BaseMatcher):
117
124
  self.boundary = boundary
118
125
 
119
126
  def matches(self, value: Any) -> bool:
120
- return value > self.boundary
127
+ try:
128
+ return value > self.boundary
129
+ except TypeError:
130
+ return False
121
131
 
122
132
  def describe(self) -> str:
123
133
  return f"a value greater than <{self.boundary}>"
@@ -128,7 +138,10 @@ class GreaterThanOrEqualToMatcher(BaseMatcher):
128
138
  self.boundary = boundary
129
139
 
130
140
  def matches(self, value: Any) -> bool:
131
- return value >= self.boundary
141
+ try:
142
+ return value >= self.boundary
143
+ except TypeError:
144
+ return False
132
145
 
133
146
  def describe(self) -> str:
134
147
  return f"a value greater than or equal to <{self.boundary}>"
@@ -139,7 +152,10 @@ class LessThanMatcher(BaseMatcher):
139
152
  self.boundary = boundary
140
153
 
141
154
  def matches(self, value: Any) -> bool:
142
- return value < self.boundary
155
+ try:
156
+ return value < self.boundary
157
+ except TypeError:
158
+ return False
143
159
 
144
160
  def describe(self) -> str:
145
161
  return f"a value less than <{self.boundary}>"
@@ -150,7 +166,10 @@ class LessThanOrEqualToMatcher(BaseMatcher):
150
166
  self.boundary = boundary
151
167
 
152
168
  def matches(self, value: Any) -> bool:
153
- return value <= self.boundary
169
+ try:
170
+ return value <= self.boundary
171
+ except TypeError:
172
+ return False
154
173
 
155
174
  def describe(self) -> str:
156
175
  return f"a value less than or equal to <{self.boundary}>"
@@ -162,7 +181,10 @@ class BetweenMatcher(BaseMatcher):
162
181
  self.high = high
163
182
 
164
183
  def matches(self, value: Any) -> bool:
165
- return self.low <= value <= self.high
184
+ try:
185
+ return self.low <= value <= self.high
186
+ except TypeError:
187
+ return False
166
188
 
167
189
  def describe(self) -> str:
168
190
  return f"a value between <{self.low}> and <{self.high}>"
@@ -174,7 +196,10 @@ class CloseToMatcher(BaseMatcher):
174
196
  self.tolerance = tolerance
175
197
 
176
198
  def matches(self, value: Any) -> bool:
177
- return abs(value - self.expected) <= self.tolerance
199
+ try:
200
+ return abs(value - self.expected) <= self.tolerance
201
+ except TypeError:
202
+ return False
178
203
 
179
204
  def describe(self) -> str:
180
205
  return f"a value within <{self.tolerance}> of <{self.expected}>"
@@ -433,8 +458,6 @@ class EndsWithMatcher(BaseMatcher):
433
458
 
434
459
 
435
460
  class IgnoreMatcher(BaseMatcher):
436
- """Always matches. Used in structure specs to accept any value for a field."""
437
-
438
461
  def matches(self, value: Any) -> bool:
439
462
  return True
440
463
 
@@ -443,8 +466,6 @@ class IgnoreMatcher(BaseMatcher):
443
466
 
444
467
 
445
468
  class IsUuidMatcher(BaseMatcher):
446
- """Matches valid UUID strings."""
447
-
448
469
  def matches(self, value: Any) -> bool:
449
470
  if not isinstance(value, str):
450
471
  return False
@@ -459,8 +480,6 @@ class IsUuidMatcher(BaseMatcher):
459
480
 
460
481
 
461
482
  class IsNonEmptyStringMatcher(BaseMatcher):
462
- """Matches non-empty strings."""
463
-
464
483
  def matches(self, value: Any) -> bool:
465
484
  return isinstance(value, str) and len(value) > 0
466
485
 
@@ -502,7 +521,7 @@ class StructureMatcher(BaseMatcher):
502
521
  def matches(self, value: Any) -> bool:
503
522
  if not isinstance(value, dict):
504
523
  return False
505
- return self._match_recursive(value, self._spec, "") is None
524
+ return self._match_recursive(value, self._spec, "", set()) is None
506
525
 
507
526
  def describe(self) -> str:
508
527
  return f"a dict matching structure {self._describe_spec(self._spec)}"
@@ -510,12 +529,16 @@ class StructureMatcher(BaseMatcher):
510
529
  def describe_mismatch(self, value: Any) -> str:
511
530
  if not isinstance(value, dict):
512
531
  return f"was not a dict: <{value}>"
513
- error = self._match_recursive(value, self._spec, "")
532
+ error = self._match_recursive(value, self._spec, "", set())
514
533
  if error:
515
534
  return error
516
535
  return f"was <{value}>"
517
536
 
518
- def _match_recursive(self, value: dict, spec: dict, path: str) -> str | None:
537
+ def _match_recursive(self, value: dict, spec: dict, path: str, seen: set[tuple[int, int]]) -> str | None:
538
+ pair_id = (id(value), id(spec))
539
+ if pair_id in seen:
540
+ return f"circular reference detected at <{path or 'root'}>"
541
+ seen.add(pair_id)
519
542
  for key, expected in spec.items():
520
543
  current_path = f"{path}.{key}" if path else str(key)
521
544
  if key not in value:
@@ -529,7 +552,7 @@ class StructureMatcher(BaseMatcher):
529
552
  elif isinstance(expected, dict):
530
553
  if not isinstance(actual, dict):
531
554
  return f"at <{current_path}>: expected a dict, but was <{actual}>"
532
- error = self._match_recursive(actual, expected, current_path)
555
+ error = self._match_recursive(actual, expected, current_path, seen)
533
556
  if error:
534
557
  return error
535
558
  elif actual != expected:
@@ -551,6 +574,7 @@ class StructureMatcher(BaseMatcher):
551
574
  # --- Custom matcher registry ---
552
575
 
553
576
  _custom_matchers: dict[str, Callable[..., BaseMatcher]] = {}
577
+ _custom_matchers_lock = threading.Lock()
554
578
 
555
579
 
556
580
  def register_matcher(name: str) -> Callable[[Callable[..., BaseMatcher]], Callable[..., BaseMatcher]]:
@@ -587,7 +611,8 @@ def register_matcher(name: str) -> Callable[[Callable[..., BaseMatcher]], Callab
587
611
  def decorator(func: Callable[..., BaseMatcher]) -> Callable[..., BaseMatcher]:
588
612
  if not callable(func):
589
613
  raise TypeError("func must be callable")
590
- _custom_matchers[name] = func
614
+ with _custom_matchers_lock:
615
+ _custom_matchers[name] = func
591
616
  return func
592
617
 
593
618
  return decorator
@@ -602,9 +627,16 @@ def unregister_matcher(name: str) -> None:
602
627
  Raises:
603
628
  KeyError: if the name is not registered
604
629
  """
605
- if name not in _custom_matchers:
606
- raise KeyError(f"no custom matcher registered with name {name!r}")
607
- del _custom_matchers[name]
630
+ with _custom_matchers_lock:
631
+ if name not in _custom_matchers:
632
+ raise KeyError(f"no custom matcher registered with name {name!r}")
633
+ del _custom_matchers[name]
634
+
635
+
636
+ def clear_custom_matchers() -> None:
637
+ """Remove all registered custom matchers."""
638
+ with _custom_matchers_lock:
639
+ _custom_matchers.clear()
608
640
 
609
641
 
610
642
  # --- Namespace ---
@@ -766,10 +798,11 @@ class _MatchNamespace:
766
798
  return StructureMatcher(spec)
767
799
 
768
800
  def __getattr__(self, name: str) -> Callable[..., BaseMatcher]:
769
- try:
770
- factory = _custom_matchers[name]
771
- except KeyError:
772
- raise AttributeError(f"match has no matcher {name!r}") from None
801
+ with _custom_matchers_lock:
802
+ try:
803
+ factory = _custom_matchers[name]
804
+ except KeyError:
805
+ raise AttributeError(f"match has no matcher {name!r}") from None
773
806
  return factory
774
807
 
775
808
 
@@ -977,6 +977,38 @@ assert_that(50).satisfies(complex_check)
977
977
  assert_that(-1).satisfies(complex_check)
978
978
  ```
979
979
 
980
+ ### Using matchers with `==`
981
+
982
+ Matchers implement `__eq__`, so they work with plain `assert` and pytest introspection. No `assert_that()` wrapper needed:
983
+
984
+ ```py
985
+ from assertpy2 import match
986
+
987
+ # simple value check
988
+ assert 42 == match.is_positive()
989
+ assert "hello" == match.is_non_empty_string()
990
+
991
+ # dict comparison with matchers as expected values
992
+ assert {"id": 5, "name": "Alice"} == {
993
+ "id": match.is_positive(),
994
+ "name": match.is_non_empty_string(),
995
+ }
996
+
997
+ # list comparison
998
+ assert [1, 2, 3] == [match.is_positive(), match.is_positive(), match.is_positive()]
999
+
1000
+ # composition works too
1001
+ assert 42 == (match.is_positive() & match.less_than(100))
1002
+ ```
1003
+
1004
+ On failure, pytest shows the matcher description in the assertion message:
1005
+
1006
+ ```
1007
+ AssertionError: assert -5 == a positive value
1008
+ ```
1009
+
1010
+ This makes matchers a drop-in addition to existing test suites: add one import, use `match.*` in any `==` comparison, no rewrite required.
1011
+
980
1012
  ### Available matchers
981
1013
 
982
1014
  | Matcher | Description |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "assertpy2"
3
- version = "2.3.3"
3
+ version = "2.3.5"
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"
@@ -80,4 +80,4 @@ select = ["E", "E301", "F", "I", "UP", "B", "SIM", "RUF"]
80
80
  ignore = ["E501"]
81
81
 
82
82
  [tool.ruff.lint.per-file-ignores]
83
- "tests/*" = ["E741", "SIM102"]
83
+ "tests/*" = ["E741", "SIM102", "SIM300"]
@@ -2,13 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  import pytest
4
4
 
5
- from assertpy2 import assert_that, match, register_matcher, unregister_matcher
6
- from assertpy2.matchers import _custom_matchers
5
+ from assertpy2 import assert_that, clear_custom_matchers, match, register_matcher, unregister_matcher
7
6
 
8
7
 
9
8
  class TestRegisterMatcher:
10
9
  def teardown_method(self):
11
- _custom_matchers.clear()
10
+ clear_custom_matchers()
12
11
 
13
12
  def test_simple_matcher(self):
14
13
  @register_matcher("is_short")
@@ -113,7 +112,7 @@ class TestRegisterMatcher:
113
112
 
114
113
  class TestRegisterMatcherErrors:
115
114
  def teardown_method(self):
116
- _custom_matchers.clear()
115
+ clear_custom_matchers()
117
116
 
118
117
  def test_name_not_string(self):
119
118
  with pytest.raises(TypeError, match="name must be a string"):
@@ -138,7 +137,7 @@ class TestRegisterMatcherErrors:
138
137
 
139
138
  class TestUnregisterMatcher:
140
139
  def teardown_method(self):
141
- _custom_matchers.clear()
140
+ clear_custom_matchers()
142
141
 
143
142
  def test_unregister(self):
144
143
  @register_matcher("temp")
@@ -153,3 +152,19 @@ class TestUnregisterMatcher:
153
152
  def test_unregister_unknown_name(self):
154
153
  with pytest.raises(KeyError, match="no custom matcher registered"):
155
154
  unregister_matcher("nonexistent")
155
+
156
+ def test_clear_custom_matchers(self):
157
+ @register_matcher("a")
158
+ def a():
159
+ return match.is_positive()
160
+
161
+ @register_matcher("b")
162
+ def b():
163
+ return match.is_negative()
164
+
165
+ assert_that(1).satisfies(match.a())
166
+ clear_custom_matchers()
167
+ with pytest.raises(AttributeError):
168
+ match.a()
169
+ with pytest.raises(AttributeError):
170
+ match.b()
@@ -118,6 +118,43 @@ class TestCloseToMatcher:
118
118
  assert_that(match.close_to(10.0, 0.5).describe()).is_equal_to("a value within <0.5> of <10.0>")
119
119
 
120
120
 
121
+ class TestOrderingMatchersIncompatibleTypes:
122
+ def test_greater_than_incompatible(self):
123
+ assert_that(match.greater_than(5).matches("hello")).is_false()
124
+
125
+ def test_greater_than_none(self):
126
+ assert_that(match.greater_than(5).matches(None)).is_false()
127
+
128
+ def test_greater_than_or_equal_to_incompatible(self):
129
+ assert_that(match.greater_than_or_equal_to(5).matches("hello")).is_false()
130
+
131
+ def test_less_than_incompatible(self):
132
+ assert_that(match.less_than(5).matches("hello")).is_false()
133
+
134
+ def test_less_than_none(self):
135
+ assert_that(match.less_than(5).matches(None)).is_false()
136
+
137
+ def test_less_than_or_equal_to_incompatible(self):
138
+ assert_that(match.less_than_or_equal_to(5).matches([1, 2])).is_false()
139
+
140
+ def test_between_incompatible(self):
141
+ assert_that(match.between(1, 10).matches("hello")).is_false()
142
+
143
+ def test_between_none(self):
144
+ assert_that(match.between(1, 10).matches(None)).is_false()
145
+
146
+ def test_close_to_incompatible(self):
147
+ assert_that(match.close_to(10.0, 0.5).matches("hello")).is_false()
148
+
149
+ def test_close_to_none(self):
150
+ assert_that(match.close_to(10.0, 0.5).matches(None)).is_false()
151
+
152
+ def test_composition_with_type_check(self):
153
+ m = match.is_instance_of(int) & match.greater_than(5)
154
+ assert_that(m.matches("hello")).is_false()
155
+ assert_that(m.matches(10)).is_true()
156
+
157
+
121
158
  class TestIsNoneMatcher:
122
159
  def test_matches(self):
123
160
  assert_that(match.is_none().matches(None)).is_true()
@@ -525,3 +562,70 @@ class TestDescribeCoverage:
525
562
 
526
563
  def test_ends_with_describe(self):
527
564
  assert_that(match.ends_with("bar").describe()).is_equal_to("a string ending with <bar>")
565
+
566
+
567
+ class TestMatcherEqProtocol:
568
+ def test_eq_positive_match(self):
569
+ assert 5 == match.is_positive()
570
+
571
+ def test_eq_negative_match(self):
572
+ assert (-5 == match.is_positive()) is False
573
+
574
+ def test_eq_equal_to(self):
575
+ assert 42 == match.equal_to(42)
576
+
577
+ def test_eq_between(self):
578
+ assert 5 == match.between(1, 10)
579
+
580
+ def test_eq_string_matcher(self):
581
+ assert "hello" == match.is_non_empty_string()
582
+
583
+ def test_eq_not_equal(self):
584
+ assert -5 != match.is_positive()
585
+
586
+ def test_eq_reverse_order(self):
587
+ assert match.is_positive() == 5
588
+
589
+ def test_eq_dict(self):
590
+ assert {"id": 5, "name": "Alice"} == {"id": match.is_positive(), "name": match.is_non_empty_string()}
591
+
592
+ def test_eq_dict_mismatch(self):
593
+ assert {"id": -5} != {"id": match.is_positive()}
594
+
595
+ def test_eq_nested_dict(self):
596
+ data = {"user": {"name": "Alice", "age": 30}}
597
+ assert data == {"user": {"name": match.is_non_empty_string(), "age": match.is_positive()}}
598
+
599
+ def test_eq_list(self):
600
+ assert [1, 2, 3] == [match.is_positive(), match.is_positive(), match.is_positive()]
601
+
602
+ def test_eq_composition_and(self):
603
+ assert 5 == (match.is_positive() & match.less_than(10))
604
+
605
+ def test_eq_composition_or(self):
606
+ assert -1 == (match.is_positive() | match.is_negative())
607
+
608
+ def test_eq_negated_matcher(self):
609
+ assert -5 == ~match.is_positive()
610
+
611
+ def test_hash_unique_instances(self):
612
+ m1 = match.is_positive()
613
+ m2 = match.is_positive()
614
+ s = {m1, m2}
615
+ assert_that(s).is_length(2)
616
+
617
+ def test_hash_same_instance(self):
618
+ m = match.is_positive()
619
+ s = {m, m}
620
+ assert_that(s).is_length(1)
621
+
622
+ def test_repr_unchanged(self):
623
+ assert_that(repr(match.is_positive())).is_equal_to("a positive value")
624
+
625
+ def test_eq_with_pytest_assert_message(self):
626
+ try:
627
+ assert -5 == match.is_positive()
628
+ except AssertionError:
629
+ pass
630
+ else:
631
+ raise AssertionError("Expected AssertionError")
@@ -218,6 +218,30 @@ class TestStructureMatcher:
218
218
  m = match.structure({"a": 1})
219
219
  assert_that(m.describe_mismatch({"a": 1})).is_equal_to("was <{'a': 1}>")
220
220
 
221
+ def test_circular_reference_detected(self):
222
+ d = {}
223
+ d["self"] = d
224
+ spec = {}
225
+ spec["self"] = spec
226
+ m = match.structure(spec)
227
+ assert_that(m.matches(d)).is_false()
228
+ assert_that(m.describe_mismatch(d)).contains("circular reference")
229
+
230
+ def test_deep_nesting(self):
231
+ value = {"a": 1}
232
+ spec = {"a": 1}
233
+ current_v = value
234
+ current_s = spec
235
+ for _i in range(20):
236
+ inner_v = {"a": 1}
237
+ inner_s = {"a": 1}
238
+ current_v["nested"] = inner_v
239
+ current_s["nested"] = inner_s
240
+ current_v = inner_v
241
+ current_s = inner_s
242
+ m = match.structure(spec)
243
+ assert_that(m.matches(value)).is_true()
244
+
221
245
 
222
246
  class TestMatchesStructureMethod:
223
247
  def test_basic(self):
@@ -30,7 +30,7 @@ wheels = [
30
30
 
31
31
  [[package]]
32
32
  name = "assertpy2"
33
- version = "2.3.1"
33
+ version = "2.3.5"
34
34
  source = { editable = "." }
35
35
  dependencies = [
36
36
  { name = "typing-extensions" },
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