assertpy2 2.5.1__tar.gz → 2.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assertpy2-2.7.0/.gitattributes +2 -0
- assertpy2-2.7.0/.github/dependabot.yml +20 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/.gitignore +1 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/PKG-INFO +1 -1
- assertpy2-2.7.0/SECURITY.md +24 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/_mixin_base.py +14 -2
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/_typing.py +16 -2
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/assertpy.py +7 -2
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/base.py +96 -86
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/collection.py +28 -28
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/contains.py +35 -30
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/dict.py +22 -22
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/errors.py +3 -3
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/exception.py +44 -8
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/extracting.py +31 -31
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/helpers.py +89 -69
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/matchers.py +7 -7
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/snapshot.py +1 -1
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/string.py +17 -17
- assertpy2-2.7.0/assertpy2/warning.py +126 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/errors.md +39 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/pyproject.toml +4 -3
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_async.py +8 -8
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_errors.py +1 -1
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_expected_exception.py +16 -4
- assertpy2-2.7.0/tests/test_expected_warning.py +203 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_extracting.py +2 -2
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_file.py +2 -2
- assertpy2-2.7.0/tests/test_property_based.py +333 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_pytest_plugin.py +2 -2
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_rich_diff.py +68 -64
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_soft.py +2 -2
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_traceback.py +3 -1
- {assertpy2-2.5.1 → assertpy2-2.7.0}/uv.lock +25 -1
- assertpy2-2.5.1/.github/dependabot.yml +0 -20
- assertpy2-2.5.1/SECURITY.md +0 -16
- {assertpy2-2.5.1 → assertpy2-2.7.0}/.codecov.yml +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/LICENSE +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/README.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/__init__.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/_compat.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/date.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/file.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/assertions.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/comparison.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/data.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/extending.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/fluent.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/getting-started.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/index.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/integrations.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/logo.svg +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/matchers.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/migration.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/docs/testing.md +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/mkdocs.yml +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_bool.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_bytes.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_class.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_collection.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_core.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_datetime.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_description.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_dict.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_dyn.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_equals.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_fail.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_in.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_json.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_list.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_matchers.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_matchers_phase3.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_none.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_not.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_phase2.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_readme.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_snapshots.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_string.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_structural.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_type.py +0 -0
- {assertpy2-2.5.1 → assertpy2-2.7.0}/tests/test_warn.py +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: pip
|
|
4
|
+
directory: /
|
|
5
|
+
schedule:
|
|
6
|
+
interval: weekly
|
|
7
|
+
cooldown:
|
|
8
|
+
default-days: 7
|
|
9
|
+
semver-major-days: 7
|
|
10
|
+
commit-message:
|
|
11
|
+
prefix: "chore(deps)"
|
|
12
|
+
|
|
13
|
+
- package-ecosystem: github-actions
|
|
14
|
+
directory: /
|
|
15
|
+
schedule:
|
|
16
|
+
interval: weekly
|
|
17
|
+
cooldown:
|
|
18
|
+
default-days: 7
|
|
19
|
+
commit-message:
|
|
20
|
+
prefix: "ci(deps)"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: Fluent assertion library for Python with composable matchers, structural matching, and full type safety
|
|
5
5
|
Project-URL: Homepage, https://github.com/Solganis/assertpy2
|
|
6
6
|
Project-URL: Repository, https://github.com/Solganis/assertpy2
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
| Version | Supported |
|
|
6
|
+
|---------|-----------|
|
|
7
|
+
| 2.x | Yes |
|
|
8
|
+
| < 2.0 | No |
|
|
9
|
+
|
|
10
|
+
## Reporting a Vulnerability
|
|
11
|
+
|
|
12
|
+
If you discover a security vulnerability, please report it responsibly:
|
|
13
|
+
|
|
14
|
+
1. **Email**: [solganis.dev@gmail.com](mailto:solganis.dev@gmail.com)
|
|
15
|
+
2. **Subject**: `[assertpy2] Security: <brief description>`
|
|
16
|
+
3. Include steps to reproduce, affected versions, and potential impact
|
|
17
|
+
|
|
18
|
+
Please do **not** open a public GitHub issue for security vulnerabilities.
|
|
19
|
+
|
|
20
|
+
## Response Timeline
|
|
21
|
+
|
|
22
|
+
- Acknowledgement: within 48 hours
|
|
23
|
+
- Initial assessment: within 7 days
|
|
24
|
+
- Fix or mitigation: depends on severity, typically within 30 days
|
|
@@ -16,6 +16,8 @@ class _MixinBase:
|
|
|
16
16
|
expected: type[BaseException] | None
|
|
17
17
|
logger: logging.LoggerAdapter
|
|
18
18
|
_not_expected: bool
|
|
19
|
+
_expected_warning: type[Warning] | None
|
|
20
|
+
_return_value: object
|
|
19
21
|
|
|
20
22
|
def error(
|
|
21
23
|
self,
|
|
@@ -31,13 +33,23 @@ class _MixinBase:
|
|
|
31
33
|
val: object,
|
|
32
34
|
description: str = ...,
|
|
33
35
|
kind: str | None = ...,
|
|
34
|
-
expected: BaseException | None = ...,
|
|
36
|
+
expected: type[BaseException] | None = ...,
|
|
35
37
|
logger: logging.LoggerAdapter | None = ...,
|
|
36
38
|
) -> Self: ...
|
|
37
39
|
|
|
40
|
+
# WarningMixin (terminals dispatched from ExceptionMixin.when_called_with)
|
|
41
|
+
|
|
42
|
+
def _when_called_with_warning(
|
|
43
|
+
self, expected: type[Warning], *some_args: object, **some_kwargs: object
|
|
44
|
+
) -> Self: ...
|
|
45
|
+
|
|
46
|
+
def _when_called_with_not_warning(
|
|
47
|
+
self, expected: type[Warning], *some_args: object, **some_kwargs: object
|
|
48
|
+
) -> Self: ...
|
|
49
|
+
|
|
38
50
|
# HelpersMixin
|
|
39
51
|
|
|
40
|
-
def _fmt_items(self,
|
|
52
|
+
def _fmt_items(self, items: object) -> str: ...
|
|
41
53
|
|
|
42
54
|
def _fmt_args_kwargs(self, *some_args: object, **some_kwargs: object) -> str: ...
|
|
43
55
|
|
|
@@ -35,7 +35,7 @@ if TYPE_CHECKING:
|
|
|
35
35
|
def is_in(self, *items: object) -> Self: ...
|
|
36
36
|
def is_not_in(self, *items: object) -> Self: ...
|
|
37
37
|
# SnapshotMixin
|
|
38
|
-
def snapshot(self, id: str | None = ..., path: str = ...) -> Self: ...
|
|
38
|
+
def snapshot(self, id: str | None = ..., path: str = ...) -> Self: ... # noqa: A002 # mirrors public snapshot() parameter
|
|
39
39
|
# NegatedBuilder
|
|
40
40
|
@property
|
|
41
41
|
def not_(self) -> NegatedBuilder: ...
|
|
@@ -215,10 +215,24 @@ if TYPE_CHECKING:
|
|
|
215
215
|
def is_hex_equal_to(self, expected_hex: str) -> Self: ...
|
|
216
216
|
def decoded_as(self, encoding: str = ...) -> _StringAssertion: ...
|
|
217
217
|
|
|
218
|
+
class _InvokedAssertion(_StringAssertion, Protocol):
|
|
219
|
+
"""Assertions available after ``when_called_with()`` captured an exception/warning message.
|
|
220
|
+
|
|
221
|
+
The captured message is a ``str`` (hence the string assertions); :meth:`returned` pivots to
|
|
222
|
+
the value the callable returned. Its static type is unknown, so ``returned()`` exposes the
|
|
223
|
+
type-agnostic core assertions (``is_equal_to``, ``is_instance_of``, ``satisfies``, ...) -
|
|
224
|
+
type-safe by construction, never advertising methods that may not apply.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def returned(self) -> _CoreAssertion: ...
|
|
228
|
+
|
|
218
229
|
class _CallableAssertion(_CoreAssertion, Protocol):
|
|
219
230
|
"""Assertions available for callable values."""
|
|
220
231
|
|
|
221
232
|
def raises(self, ex: type) -> Self: ...
|
|
222
233
|
def does_not_raise(self, ex: type) -> Self: ...
|
|
223
|
-
def
|
|
234
|
+
def warns(self, warning: type[Warning] = ...) -> Self: ...
|
|
235
|
+
def does_not_warn(self, warning: type[Warning] = ...) -> Self: ...
|
|
236
|
+
# captured value (exception or warning message) is a str; returned() pivots to the call result
|
|
237
|
+
def when_called_with(self, *some_args: object, **some_kwargs: object) -> _InvokedAssertion: ...
|
|
224
238
|
def eventually(self, *, timeout: float = ..., interval: float = ...) -> AsyncAssertionBuilder: ...
|
|
@@ -37,7 +37,7 @@ from .date import DateMixin
|
|
|
37
37
|
from .dict import DictMixin
|
|
38
38
|
from .dynamic import DynamicMixin
|
|
39
39
|
from .errors import AssertionFailure
|
|
40
|
-
from .exception import ExceptionMixin
|
|
40
|
+
from .exception import _UNSET, ExceptionMixin
|
|
41
41
|
from .extracting import ExtractingMixin
|
|
42
42
|
from .file import FileMixin
|
|
43
43
|
from .helpers import HelpersMixin
|
|
@@ -45,8 +45,9 @@ from .json_mixin import JsonMixin
|
|
|
45
45
|
from .numeric import NumericMixin
|
|
46
46
|
from .snapshot import SnapshotMixin
|
|
47
47
|
from .string import StringMixin
|
|
48
|
+
from .warning import WarningMixin
|
|
48
49
|
|
|
49
|
-
__version__ = "2.
|
|
50
|
+
__version__ = "2.7.0"
|
|
50
51
|
|
|
51
52
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
52
53
|
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
@@ -75,6 +76,7 @@ ASSERTPY_FILES = [
|
|
|
75
76
|
"pytest_plugin.py",
|
|
76
77
|
"snapshot.py",
|
|
77
78
|
"string.py",
|
|
79
|
+
"warning.py",
|
|
78
80
|
]
|
|
79
81
|
]
|
|
80
82
|
|
|
@@ -555,6 +557,7 @@ class AssertionBuilder(
|
|
|
555
557
|
FileMixin,
|
|
556
558
|
ExtractingMixin,
|
|
557
559
|
ExceptionMixin,
|
|
560
|
+
WarningMixin,
|
|
558
561
|
DynamicMixin,
|
|
559
562
|
DictMixin,
|
|
560
563
|
DateMixin,
|
|
@@ -585,6 +588,8 @@ class AssertionBuilder(
|
|
|
585
588
|
self.expected = expected
|
|
586
589
|
self.logger = logger if logger else _default_logger
|
|
587
590
|
self._not_expected = False
|
|
591
|
+
self._expected_warning = None
|
|
592
|
+
self._return_value = _UNSET
|
|
588
593
|
|
|
589
594
|
@property
|
|
590
595
|
def not_(self) -> NegatedBuilder:
|
|
@@ -175,17 +175,17 @@ class BaseMixin(_MixinBase):
|
|
|
175
175
|
actual=actual,
|
|
176
176
|
expected=expected,
|
|
177
177
|
)
|
|
178
|
-
for
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if
|
|
182
|
-
if self._dict_not_equal(
|
|
183
|
-
self._dict_err(
|
|
184
|
-
elif
|
|
178
|
+
for index, (actual_item, expected_item) in enumerate(zip(actual, expected, strict=True)):
|
|
179
|
+
actual_dict = self._to_comparable_dict(actual_item)
|
|
180
|
+
expected_dict = self._to_comparable_dict(expected_item)
|
|
181
|
+
if actual_dict is not None and expected_dict is not None:
|
|
182
|
+
if self._dict_not_equal(actual_dict, expected_dict, ignore=ignore, include=include):
|
|
183
|
+
self._dict_err(actual_dict, expected_dict, ignore=ignore, include=include)
|
|
184
|
+
elif actual_item != expected_item:
|
|
185
185
|
return self.error(
|
|
186
|
-
f"Expected item at index <{
|
|
187
|
-
actual=
|
|
188
|
-
expected=
|
|
186
|
+
f"Expected item at index <{index}> to be equal to <{expected_item}>, but was <{actual_item}>.",
|
|
187
|
+
actual=actual_item,
|
|
188
|
+
expected=expected_item,
|
|
189
189
|
)
|
|
190
190
|
|
|
191
191
|
@staticmethod
|
|
@@ -202,11 +202,11 @@ class BaseMixin(_MixinBase):
|
|
|
202
202
|
)
|
|
203
203
|
_seen = _seen | {pair_key[0], pair_key[1]}
|
|
204
204
|
|
|
205
|
-
def _field_entries(
|
|
206
|
-
|
|
207
|
-
if
|
|
208
|
-
return
|
|
209
|
-
return [DiffEntry(path=path, actual=
|
|
205
|
+
def _field_entries(actual_value: object, expected_value: object, path: str) -> list[DiffEntry]:
|
|
206
|
+
sub_entries = BaseMixin._sub_diff_entries(actual_value, expected_value, path, _seen=_seen)
|
|
207
|
+
if sub_entries is not None:
|
|
208
|
+
return sub_entries
|
|
209
|
+
return [DiffEntry(path=path, actual=actual_value, expected=expected_value)]
|
|
210
210
|
|
|
211
211
|
if (
|
|
212
212
|
hasattr(actual, "_fields")
|
|
@@ -216,13 +216,13 @@ class BaseMixin(_MixinBase):
|
|
|
216
216
|
):
|
|
217
217
|
entries: list[DiffEntry] = []
|
|
218
218
|
for field in actual._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
actual_value = getattr(actual, field)
|
|
220
|
+
expected_value = getattr(expected, field, _SENTINEL)
|
|
221
221
|
path = f"{_prefix}.{field}"
|
|
222
|
-
if
|
|
223
|
-
entries.append(DiffEntry(path=path, actual=
|
|
224
|
-
elif
|
|
225
|
-
entries.extend(_field_entries(
|
|
222
|
+
if expected_value is _SENTINEL:
|
|
223
|
+
entries.append(DiffEntry(path=path, actual=actual_value, expected=None))
|
|
224
|
+
elif actual_value != expected_value:
|
|
225
|
+
entries.extend(_field_entries(actual_value, expected_value, path))
|
|
226
226
|
for field in expected._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
|
|
227
227
|
if not hasattr(actual, field):
|
|
228
228
|
entries.append(DiffEntry(path=f"{_prefix}.{field}", actual=None, expected=getattr(expected, field)))
|
|
@@ -234,8 +234,8 @@ class BaseMixin(_MixinBase):
|
|
|
234
234
|
and not isinstance(expected, type)
|
|
235
235
|
):
|
|
236
236
|
entries = []
|
|
237
|
-
actual_names = {
|
|
238
|
-
expected_names = {
|
|
237
|
+
actual_names = {field.name for field in dataclasses.fields(actual)}
|
|
238
|
+
expected_names = {field.name for field in dataclasses.fields(expected)}
|
|
239
239
|
for field in sorted(actual_names | expected_names):
|
|
240
240
|
path = f"{_prefix}.{field}"
|
|
241
241
|
if field not in expected_names:
|
|
@@ -243,27 +243,27 @@ class BaseMixin(_MixinBase):
|
|
|
243
243
|
elif field not in actual_names:
|
|
244
244
|
entries.append(DiffEntry(path=path, actual=None, expected=getattr(expected, field)))
|
|
245
245
|
else:
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if
|
|
249
|
-
entries.extend(_field_entries(
|
|
246
|
+
actual_value = getattr(actual, field)
|
|
247
|
+
expected_value = getattr(expected, field)
|
|
248
|
+
if actual_value != expected_value:
|
|
249
|
+
entries.extend(_field_entries(actual_value, expected_value, path))
|
|
250
250
|
return DiffResult(kind="dataclass", entries=entries)
|
|
251
251
|
if _is_model_dump_object(actual) and _is_model_dump_object(expected):
|
|
252
252
|
actual_dict = actual.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
|
|
253
253
|
expected_dict = expected.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
|
|
254
254
|
entries = []
|
|
255
|
-
for
|
|
256
|
-
path = f"{_prefix}.{
|
|
257
|
-
if
|
|
258
|
-
entries.append(DiffEntry(path=path, actual=actual_dict[
|
|
259
|
-
elif
|
|
260
|
-
entries.append(DiffEntry(path=path, actual=None, expected=expected_dict[
|
|
261
|
-
elif actual_dict[
|
|
262
|
-
|
|
263
|
-
if
|
|
264
|
-
entries.extend(
|
|
255
|
+
for key in sorted(set(actual_dict) | set(expected_dict)):
|
|
256
|
+
path = f"{_prefix}.{key}" if _prefix else f".{key}"
|
|
257
|
+
if key not in expected_dict:
|
|
258
|
+
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=None))
|
|
259
|
+
elif key not in actual_dict:
|
|
260
|
+
entries.append(DiffEntry(path=path, actual=None, expected=expected_dict[key]))
|
|
261
|
+
elif actual_dict[key] != expected_dict[key]:
|
|
262
|
+
sub_entries = BaseMixin._sub_diff_entries(actual_dict[key], expected_dict[key], path, _seen=_seen)
|
|
263
|
+
if sub_entries is not None:
|
|
264
|
+
entries.extend(sub_entries)
|
|
265
265
|
else:
|
|
266
|
-
entries.append(DiffEntry(path=path, actual=actual_dict[
|
|
266
|
+
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
|
|
267
267
|
return DiffResult(kind="model", entries=entries)
|
|
268
268
|
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
|
|
269
269
|
entries = []
|
|
@@ -275,9 +275,9 @@ class BaseMixin(_MixinBase):
|
|
|
275
275
|
elif i >= len(expected):
|
|
276
276
|
entries.append(DiffEntry(path=path, actual=actual[i], expected=None))
|
|
277
277
|
elif actual[i] != expected[i]:
|
|
278
|
-
|
|
279
|
-
if
|
|
280
|
-
entries.extend(
|
|
278
|
+
sub_entries = BaseMixin._sub_diff_entries(actual[i], expected[i], path, _seen=_seen)
|
|
279
|
+
if sub_entries:
|
|
280
|
+
entries.extend(sub_entries)
|
|
281
281
|
else:
|
|
282
282
|
entries.append(DiffEntry(path=path, actual=actual[i], expected=expected[i]))
|
|
283
283
|
return DiffResult(kind="sequence", entries=entries)
|
|
@@ -317,18 +317,18 @@ class BaseMixin(_MixinBase):
|
|
|
317
317
|
if isinstance(actual, dict) and isinstance(expected, dict):
|
|
318
318
|
child_seen = _seen | {id(actual), id(expected)}
|
|
319
319
|
entries: list[DiffEntry] = []
|
|
320
|
-
for
|
|
321
|
-
path = f"{prefix}.{
|
|
322
|
-
if
|
|
323
|
-
entries.append(DiffEntry(path=path, actual=actual[
|
|
324
|
-
elif
|
|
325
|
-
entries.append(DiffEntry(path=path, actual=None, expected=expected[
|
|
326
|
-
elif actual[
|
|
327
|
-
|
|
328
|
-
if
|
|
329
|
-
entries.extend(
|
|
320
|
+
for key in sorted(set(actual) | set(expected), key=repr):
|
|
321
|
+
path = f"{prefix}.{key}"
|
|
322
|
+
if key not in expected:
|
|
323
|
+
entries.append(DiffEntry(path=path, actual=actual[key], expected=None)) # ty: ignore[invalid-argument-type]
|
|
324
|
+
elif key not in actual:
|
|
325
|
+
entries.append(DiffEntry(path=path, actual=None, expected=expected[key])) # ty: ignore[invalid-argument-type]
|
|
326
|
+
elif actual[key] != expected[key]: # ty: ignore[invalid-argument-type]
|
|
327
|
+
sub_entries = BaseMixin._sub_diff_entries(actual[key], expected[key], path, _seen=child_seen) # ty: ignore[invalid-argument-type]
|
|
328
|
+
if sub_entries is not None:
|
|
329
|
+
entries.extend(sub_entries)
|
|
330
330
|
else:
|
|
331
|
-
entries.append(DiffEntry(path=path, actual=actual[
|
|
331
|
+
entries.append(DiffEntry(path=path, actual=actual[key], expected=expected[key])) # ty: ignore[invalid-argument-type]
|
|
332
332
|
return entries or None
|
|
333
333
|
if (
|
|
334
334
|
dataclasses.is_dataclass(actual)
|
|
@@ -338,17 +338,21 @@ class BaseMixin(_MixinBase):
|
|
|
338
338
|
):
|
|
339
339
|
child_seen = _seen | {id(actual), id(expected)}
|
|
340
340
|
entries = []
|
|
341
|
-
for
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if
|
|
345
|
-
entries.append(DiffEntry(path=f"{prefix}.{
|
|
346
|
-
elif
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
341
|
+
for field in dataclasses.fields(actual):
|
|
342
|
+
actual_value = getattr(actual, field.name)
|
|
343
|
+
expected_value = getattr(expected, field.name, _SENTINEL)
|
|
344
|
+
if expected_value is _SENTINEL:
|
|
345
|
+
entries.append(DiffEntry(path=f"{prefix}.{field.name}", actual=actual_value, expected=None))
|
|
346
|
+
elif actual_value != expected_value:
|
|
347
|
+
sub_entries = BaseMixin._sub_diff_entries(
|
|
348
|
+
actual_value, expected_value, f"{prefix}.{field.name}", _seen=child_seen
|
|
349
|
+
)
|
|
350
|
+
if sub_entries is not None:
|
|
351
|
+
entries.extend(sub_entries)
|
|
350
352
|
else:
|
|
351
|
-
entries.append(
|
|
353
|
+
entries.append(
|
|
354
|
+
DiffEntry(path=f"{prefix}.{field.name}", actual=actual_value, expected=expected_value)
|
|
355
|
+
)
|
|
352
356
|
return entries or None
|
|
353
357
|
if (
|
|
354
358
|
hasattr(actual, "_fields")
|
|
@@ -359,16 +363,20 @@ class BaseMixin(_MixinBase):
|
|
|
359
363
|
child_seen = _seen | {id(actual), id(expected)}
|
|
360
364
|
entries = []
|
|
361
365
|
for field in actual._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if
|
|
365
|
-
entries.append(DiffEntry(path=f"{prefix}.{field}", actual=
|
|
366
|
-
elif
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
366
|
+
actual_value = getattr(actual, field)
|
|
367
|
+
expected_value = getattr(expected, field, _SENTINEL)
|
|
368
|
+
if expected_value is _SENTINEL:
|
|
369
|
+
entries.append(DiffEntry(path=f"{prefix}.{field}", actual=actual_value, expected=None))
|
|
370
|
+
elif actual_value != expected_value:
|
|
371
|
+
sub_entries = BaseMixin._sub_diff_entries(
|
|
372
|
+
actual_value, expected_value, f"{prefix}.{field}", _seen=child_seen
|
|
373
|
+
)
|
|
374
|
+
if sub_entries is not None:
|
|
375
|
+
entries.extend(sub_entries)
|
|
370
376
|
else:
|
|
371
|
-
entries.append(
|
|
377
|
+
entries.append(
|
|
378
|
+
DiffEntry(path=f"{prefix}.{field}", actual=actual_value, expected=expected_value)
|
|
379
|
+
)
|
|
372
380
|
for field in expected._fields: # ty: ignore[not-iterable] # guarded by hasattr check above
|
|
373
381
|
if not hasattr(actual, field):
|
|
374
382
|
entries.append(DiffEntry(path=f"{prefix}.{field}", actual=None, expected=getattr(expected, field)))
|
|
@@ -378,18 +386,20 @@ class BaseMixin(_MixinBase):
|
|
|
378
386
|
actual_dict = actual.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
|
|
379
387
|
expected_dict = expected.model_dump() # ty: ignore[unresolved-attribute] # guarded by _is_model_dump_object check above
|
|
380
388
|
entries = []
|
|
381
|
-
for
|
|
382
|
-
path = f"{prefix}.{
|
|
383
|
-
if
|
|
384
|
-
entries.append(DiffEntry(path=path, actual=actual_dict[
|
|
385
|
-
elif
|
|
386
|
-
entries.append(DiffEntry(path=path, actual=None, expected=expected_dict[
|
|
387
|
-
elif actual_dict[
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
389
|
+
for key in sorted(set(actual_dict) | set(expected_dict)):
|
|
390
|
+
path = f"{prefix}.{key}"
|
|
391
|
+
if key not in expected_dict:
|
|
392
|
+
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=None))
|
|
393
|
+
elif key not in actual_dict:
|
|
394
|
+
entries.append(DiffEntry(path=path, actual=None, expected=expected_dict[key]))
|
|
395
|
+
elif actual_dict[key] != expected_dict[key]:
|
|
396
|
+
sub_entries = BaseMixin._sub_diff_entries(
|
|
397
|
+
actual_dict[key], expected_dict[key], path, _seen=child_seen
|
|
398
|
+
)
|
|
399
|
+
if sub_entries is not None:
|
|
400
|
+
entries.extend(sub_entries)
|
|
391
401
|
else:
|
|
392
|
-
entries.append(DiffEntry(path=path, actual=actual_dict[
|
|
402
|
+
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
|
|
393
403
|
return entries or None
|
|
394
404
|
return None
|
|
395
405
|
|
|
@@ -875,8 +885,8 @@ class BaseMixin(_MixinBase):
|
|
|
875
885
|
if type(some_type) is not type and not issubclass(type(some_type), type):
|
|
876
886
|
raise TypeError("given arg must be a type")
|
|
877
887
|
if type(self.val) is not some_type:
|
|
878
|
-
|
|
879
|
-
return self.error(f"Expected <{self.val}:{
|
|
888
|
+
type_name = self._type(self.val)
|
|
889
|
+
return self.error(f"Expected <{self.val}:{type_name}> to be of type <{some_type.__name__}>, but was not.")
|
|
880
890
|
return self
|
|
881
891
|
|
|
882
892
|
def is_instance_of(self, some_class) -> Self:
|
|
@@ -912,9 +922,9 @@ class BaseMixin(_MixinBase):
|
|
|
912
922
|
"""
|
|
913
923
|
try:
|
|
914
924
|
if not isinstance(self.val, some_class):
|
|
915
|
-
|
|
925
|
+
type_name = self._type(self.val)
|
|
916
926
|
return self.error(
|
|
917
|
-
f"Expected <{self.val}:{
|
|
927
|
+
f"Expected <{self.val}:{type_name}> to be instance of class <{some_class.__name__}>, but was not."
|
|
918
928
|
)
|
|
919
929
|
except TypeError:
|
|
920
930
|
raise TypeError("given arg must be a class") from None
|
|
@@ -93,36 +93,36 @@ class CollectionMixin(_MixinBase):
|
|
|
93
93
|
missing = []
|
|
94
94
|
if hasattr(self.val, "keys") and callable(self.val.keys) and hasattr(self.val, "__getitem__"):
|
|
95
95
|
superdict = {}
|
|
96
|
-
for
|
|
97
|
-
self._check_dict_like(
|
|
98
|
-
for
|
|
99
|
-
superdict.update({
|
|
100
|
-
|
|
101
|
-
for
|
|
102
|
-
if
|
|
103
|
-
missing.append({
|
|
104
|
-
elif self.val[
|
|
105
|
-
missing.append({
|
|
96
|
+
for superset_index, superset in enumerate(supersets):
|
|
97
|
+
self._check_dict_like(superset, check_values=False, name=f"arg #{superset_index + 1}")
|
|
98
|
+
for key in superset:
|
|
99
|
+
superdict.update({key: superset[key]})
|
|
100
|
+
|
|
101
|
+
for key in self.val:
|
|
102
|
+
if key not in superdict:
|
|
103
|
+
missing.append({key: self.val[key]}) # bad key
|
|
104
|
+
elif self.val[key] != superdict[key]:
|
|
105
|
+
missing.append({key: self.val[key]}) # bad val
|
|
106
106
|
if missing:
|
|
107
107
|
return self.error(
|
|
108
108
|
f"Expected <{self.val}> to be subset of {self._fmt_items(superdict)}, "
|
|
109
109
|
f"but {self._fmt_items(missing)} {'was' if len(missing) == 1 else 'were'} missing."
|
|
110
110
|
)
|
|
111
111
|
else:
|
|
112
|
-
|
|
113
|
-
for
|
|
112
|
+
superset_values = set()
|
|
113
|
+
for superset in supersets:
|
|
114
114
|
try:
|
|
115
|
-
for
|
|
116
|
-
|
|
115
|
+
for key in superset:
|
|
116
|
+
superset_values.add(key)
|
|
117
117
|
except TypeError:
|
|
118
|
-
|
|
118
|
+
superset_values.add(superset)
|
|
119
119
|
|
|
120
|
-
for
|
|
121
|
-
if
|
|
122
|
-
missing.append(
|
|
120
|
+
for item in self.val:
|
|
121
|
+
if item not in superset_values:
|
|
122
|
+
missing.append(item)
|
|
123
123
|
if missing:
|
|
124
124
|
return self.error(
|
|
125
|
-
f"Expected <{self.val}> to be subset of {self._fmt_items(
|
|
125
|
+
f"Expected <{self.val}> to be subset of {self._fmt_items(superset_values)}, "
|
|
126
126
|
f"but {self._fmt_items(missing)} {'was' if len(missing) == 1 else 'were'} missing."
|
|
127
127
|
)
|
|
128
128
|
|
|
@@ -161,22 +161,22 @@ class CollectionMixin(_MixinBase):
|
|
|
161
161
|
if not isinstance(self.val, collections.abc.Iterable):
|
|
162
162
|
raise TypeError("val is not iterable")
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
for
|
|
166
|
-
if
|
|
164
|
+
previous = None
|
|
165
|
+
for index, current in enumerate(self.val):
|
|
166
|
+
if index > 0:
|
|
167
167
|
if reverse:
|
|
168
|
-
if key(
|
|
168
|
+
if key(current) > key(previous):
|
|
169
169
|
return self.error(
|
|
170
170
|
f"Expected <{self.val}> to be sorted reverse, "
|
|
171
|
-
f"but subset {self._fmt_items([
|
|
171
|
+
f"but subset {self._fmt_items([previous, current])} at index {index - 1} is not."
|
|
172
172
|
)
|
|
173
173
|
else:
|
|
174
|
-
if key(
|
|
174
|
+
if key(current) < key(previous):
|
|
175
175
|
return self.error(
|
|
176
176
|
f"Expected <{self.val}> to be sorted, "
|
|
177
|
-
f"but subset {self._fmt_items([
|
|
177
|
+
f"but subset {self._fmt_items([previous, current])} at index {index - 1} is not."
|
|
178
178
|
)
|
|
179
|
-
|
|
179
|
+
previous = current
|
|
180
180
|
|
|
181
181
|
return self
|
|
182
182
|
|
|
@@ -237,7 +237,7 @@ class CollectionMixin(_MixinBase):
|
|
|
237
237
|
"""
|
|
238
238
|
if not isinstance(self.val, collections.abc.Iterable):
|
|
239
239
|
raise TypeError("val is not iterable")
|
|
240
|
-
return self.builder([
|
|
240
|
+
return self.builder([inner for item in self.val for inner in func(item)], self.description, self.kind)
|
|
241
241
|
|
|
242
242
|
def first(self) -> Self:
|
|
243
243
|
"""Returns a new builder with the first element of val.
|