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.
- {assertpy2-2.3.3 → assertpy2-2.3.5}/PKG-INFO +27 -18
- {assertpy2-2.3.3 → assertpy2-2.3.5}/README.md +26 -17
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/__init__.py +2 -1
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/assertpy.py +1 -4
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/collection.py +0 -2
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/exception.py +0 -1
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/helpers.py +0 -6
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/matchers.py +57 -24
- {assertpy2-2.3.3 → assertpy2-2.3.5}/docs/api.md +32 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/pyproject.toml +2 -2
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_matcher_registry.py +20 -5
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_matchers.py +104 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_structural.py +24 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/uv.lock +1 -1
- {assertpy2-2.3.3 → assertpy2-2.3.5}/.codecov.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/.github/dependabot.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/.gitignore +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/CONTRIBUTING.md +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/LICENSE +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/SECURITY.md +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/_mixin_base.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/_typing.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/base.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/contains.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/date.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/dict.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/errors.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/extracting.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/file.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/numeric.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/py.typed +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/assertpy2/string.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/docs/logo-dark.svg +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/docs/logo.svg +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_async.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_bool.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_class.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_collection.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_core.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_custom_list.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_datetime.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_description.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_dict.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_dyn.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_equals.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_errors.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_extensions.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_extracting.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_fail.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_file.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_in.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_list.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_matchers_phase3.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_none.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_numbers.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_overloads.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_phase2.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_readme.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_same_as.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_snapshots.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_soft.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_string.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_traceback.py +0 -0
- {assertpy2-2.3.3 → assertpy2-2.3.5}/tests/test_type.py +0 -0
- {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
|
+
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
|
|
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 `&`, `|`, `~`. 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
|
|
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
|
|
@@ -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
|
|
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 `&`, `|`, `~`. 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
|
|
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
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
+
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
|
-
|
|
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()
|
|
@@ -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):
|
|
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
|