assertpy2 2.3.3__tar.gz → 2.3.4__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.4}/PKG-INFO +18 -18
  2. {assertpy2-2.3.3 → assertpy2-2.3.4}/README.md +17 -17
  3. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/__init__.py +2 -1
  4. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/assertpy.py +1 -1
  5. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/matchers.py +51 -18
  6. {assertpy2-2.3.3 → assertpy2-2.3.4}/pyproject.toml +1 -1
  7. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_matcher_registry.py +20 -5
  8. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_matchers.py +37 -0
  9. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_structural.py +24 -0
  10. {assertpy2-2.3.3 → assertpy2-2.3.4}/.codecov.yml +0 -0
  11. {assertpy2-2.3.3 → assertpy2-2.3.4}/.github/dependabot.yml +0 -0
  12. {assertpy2-2.3.3 → assertpy2-2.3.4}/.github/workflows/ci.yml +0 -0
  13. {assertpy2-2.3.3 → assertpy2-2.3.4}/.github/workflows/codeql.yml +0 -0
  14. {assertpy2-2.3.3 → assertpy2-2.3.4}/.github/workflows/publish.yml +0 -0
  15. {assertpy2-2.3.3 → assertpy2-2.3.4}/.github/workflows/scorecard.yml +0 -0
  16. {assertpy2-2.3.3 → assertpy2-2.3.4}/.gitignore +0 -0
  17. {assertpy2-2.3.3 → assertpy2-2.3.4}/CONTRIBUTING.md +0 -0
  18. {assertpy2-2.3.3 → assertpy2-2.3.4}/LICENSE +0 -0
  19. {assertpy2-2.3.3 → assertpy2-2.3.4}/SECURITY.md +0 -0
  20. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/_mixin_base.py +0 -0
  21. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/_typing.py +0 -0
  22. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/async_assertions.py +0 -0
  23. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/base.py +0 -0
  24. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/behave_matchers.py +0 -0
  25. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/collection.py +0 -0
  26. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/contains.py +0 -0
  27. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/date.py +0 -0
  28. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/dict.py +0 -0
  29. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/dynamic.py +0 -0
  30. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/errors.py +0 -0
  31. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/exception.py +0 -0
  32. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/extracting.py +0 -0
  33. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/file.py +0 -0
  34. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/helpers.py +0 -0
  35. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/numeric.py +0 -0
  36. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/py.typed +0 -0
  37. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/pytest_plugin.py +0 -0
  38. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/snapshot.py +0 -0
  39. {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/string.py +0 -0
  40. {assertpy2-2.3.3 → assertpy2-2.3.4}/docs/api.md +0 -0
  41. {assertpy2-2.3.3 → assertpy2-2.3.4}/docs/logo-dark.svg +0 -0
  42. {assertpy2-2.3.3 → assertpy2-2.3.4}/docs/logo.svg +0 -0
  43. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_async.py +0 -0
  44. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_behave_matchers.py +0 -0
  45. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_bool.py +0 -0
  46. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_class.py +0 -0
  47. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_collection.py +0 -0
  48. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_core.py +0 -0
  49. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_custom_dict.py +0 -0
  50. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_custom_list.py +0 -0
  51. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_datetime.py +0 -0
  52. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_description.py +0 -0
  53. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_dict.py +0 -0
  54. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_dict_compare.py +0 -0
  55. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_dyn.py +0 -0
  56. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_equals.py +0 -0
  57. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_errors.py +0 -0
  58. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_expected_exception.py +0 -0
  59. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_extensions.py +0 -0
  60. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_extracting.py +0 -0
  61. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_fail.py +0 -0
  62. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_file.py +0 -0
  63. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_in.py +0 -0
  64. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_list.py +0 -0
  65. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_matchers_phase3.py +0 -0
  66. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_namedtuple.py +0 -0
  67. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_none.py +0 -0
  68. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_numbers.py +0 -0
  69. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_overloads.py +0 -0
  70. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_phase2.py +0 -0
  71. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_pytest_plugin.py +0 -0
  72. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_readme.py +0 -0
  73. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_regex_groups.py +0 -0
  74. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_same_as.py +0 -0
  75. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_snapshots.py +0 -0
  76. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_soft.py +0 -0
  77. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_soft_fail.py +0 -0
  78. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_string.py +0 -0
  79. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_traceback.py +0 -0
  80. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_type.py +0 -0
  81. {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_warn.py +0 -0
  82. {assertpy2-2.3.3 → assertpy2-2.3.4}/uv.lock +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.4
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 `&`, `|`, `~` operators.
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
@@ -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 `&`, `|`, `~` operators.
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
@@ -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.4"
77
77
 
78
78
  __tracebackhide__ = True # clean tracebacks via py.test integration
79
79
  contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
@@ -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
@@ -117,7 +118,10 @@ class GreaterThanMatcher(BaseMatcher):
117
118
  self.boundary = boundary
118
119
 
119
120
  def matches(self, value: Any) -> bool:
120
- return value > self.boundary
121
+ try:
122
+ return value > self.boundary
123
+ except TypeError:
124
+ return False
121
125
 
122
126
  def describe(self) -> str:
123
127
  return f"a value greater than <{self.boundary}>"
@@ -128,7 +132,10 @@ class GreaterThanOrEqualToMatcher(BaseMatcher):
128
132
  self.boundary = boundary
129
133
 
130
134
  def matches(self, value: Any) -> bool:
131
- return value >= self.boundary
135
+ try:
136
+ return value >= self.boundary
137
+ except TypeError:
138
+ return False
132
139
 
133
140
  def describe(self) -> str:
134
141
  return f"a value greater than or equal to <{self.boundary}>"
@@ -139,7 +146,10 @@ class LessThanMatcher(BaseMatcher):
139
146
  self.boundary = boundary
140
147
 
141
148
  def matches(self, value: Any) -> bool:
142
- return value < self.boundary
149
+ try:
150
+ return value < self.boundary
151
+ except TypeError:
152
+ return False
143
153
 
144
154
  def describe(self) -> str:
145
155
  return f"a value less than <{self.boundary}>"
@@ -150,7 +160,10 @@ class LessThanOrEqualToMatcher(BaseMatcher):
150
160
  self.boundary = boundary
151
161
 
152
162
  def matches(self, value: Any) -> bool:
153
- return value <= self.boundary
163
+ try:
164
+ return value <= self.boundary
165
+ except TypeError:
166
+ return False
154
167
 
155
168
  def describe(self) -> str:
156
169
  return f"a value less than or equal to <{self.boundary}>"
@@ -162,7 +175,10 @@ class BetweenMatcher(BaseMatcher):
162
175
  self.high = high
163
176
 
164
177
  def matches(self, value: Any) -> bool:
165
- return self.low <= value <= self.high
178
+ try:
179
+ return self.low <= value <= self.high
180
+ except TypeError:
181
+ return False
166
182
 
167
183
  def describe(self) -> str:
168
184
  return f"a value between <{self.low}> and <{self.high}>"
@@ -174,7 +190,10 @@ class CloseToMatcher(BaseMatcher):
174
190
  self.tolerance = tolerance
175
191
 
176
192
  def matches(self, value: Any) -> bool:
177
- return abs(value - self.expected) <= self.tolerance
193
+ try:
194
+ return abs(value - self.expected) <= self.tolerance
195
+ except TypeError:
196
+ return False
178
197
 
179
198
  def describe(self) -> str:
180
199
  return f"a value within <{self.tolerance}> of <{self.expected}>"
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "assertpy2"
3
- version = "2.3.3"
3
+ version = "2.3.4"
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"
@@ -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()
@@ -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):
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