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.
- {assertpy2-2.3.3 → assertpy2-2.3.4}/PKG-INFO +18 -18
- {assertpy2-2.3.3 → assertpy2-2.3.4}/README.md +17 -17
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/__init__.py +2 -1
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/assertpy.py +1 -1
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/matchers.py +51 -18
- {assertpy2-2.3.3 → assertpy2-2.3.4}/pyproject.toml +1 -1
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_matcher_registry.py +20 -5
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_matchers.py +37 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_structural.py +24 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/.codecov.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/.github/dependabot.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/.gitignore +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/CONTRIBUTING.md +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/LICENSE +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/SECURITY.md +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/_mixin_base.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/_typing.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/base.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/collection.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/contains.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/date.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/dict.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/errors.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/exception.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/extracting.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/file.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/helpers.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/numeric.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/py.typed +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/assertpy2/string.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/docs/api.md +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/docs/logo-dark.svg +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/docs/logo.svg +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_async.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_bool.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_class.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_collection.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_core.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_custom_list.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_datetime.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_description.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_dict.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_dyn.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_equals.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_errors.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_extensions.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_extracting.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_fail.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_file.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_in.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_list.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_matchers_phase3.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_none.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_numbers.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_overloads.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_phase2.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_readme.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_same_as.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_snapshots.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_soft.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_string.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_traceback.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_type.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.4}/tests/test_warn.py +0 -0
- {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
|
+
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
|
|
164
|
-
- **Structural matching
|
|
165
|
-
- **Async assertions
|
|
166
|
-
- **Structured errors
|
|
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
|
|
170
|
-
- **Fluent chaining
|
|
171
|
-
- **Dynamic assertions
|
|
172
|
-
- **Dict comparison
|
|
173
|
-
- **Extracting
|
|
174
|
-
- **File assertions
|
|
175
|
-
- **Snapshot testing
|
|
176
|
-
- **Allure integration
|
|
177
|
-
- **Behave step matchers
|
|
178
|
-
- **Custom matchers
|
|
179
|
-
- **Regex group extraction
|
|
180
|
-
- **Extensions
|
|
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
|
|
129
|
-
- **Structural matching
|
|
130
|
-
- **Async assertions
|
|
131
|
-
- **Structured errors
|
|
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
|
|
135
|
-
- **Fluent chaining
|
|
136
|
-
- **Dynamic assertions
|
|
137
|
-
- **Dict comparison
|
|
138
|
-
- **Extracting
|
|
139
|
-
- **File assertions
|
|
140
|
-
- **Snapshot testing
|
|
141
|
-
- **Allure integration
|
|
142
|
-
- **Behave step matchers
|
|
143
|
-
- **Custom matchers
|
|
144
|
-
- **Regex group extraction
|
|
145
|
-
- **Extensions
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|