assertpy2 2.3.6__tar.gz → 2.3.7__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.6 → assertpy2-2.3.7}/PKG-INFO +38 -4
- {assertpy2-2.3.6 → assertpy2-2.3.7}/README.md +33 -2
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/__init__.py +4 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/_typing.py +14 -1
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/assertpy.py +83 -8
- assertpy2-2.3.7/assertpy2/json_mixin.py +163 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/docs/api.md +112 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/pyproject.toml +4 -3
- assertpy2-2.3.7/tests/test_grouped_soft.py +130 -0
- assertpy2-2.3.7/tests/test_json.py +169 -0
- assertpy2-2.3.7/uv.lock +792 -0
- assertpy2-2.3.6/uv.lock +0 -465
- {assertpy2-2.3.6 → assertpy2-2.3.7}/.codecov.yml +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/.github/dependabot.yml +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/.gitignore +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/CONTRIBUTING.md +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/LICENSE +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/SECURITY.md +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/_mixin_base.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/base.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/collection.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/contains.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/date.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/dict.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/errors.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/exception.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/extracting.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/file.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/helpers.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/matchers.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/numeric.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/py.typed +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/string.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/docs/logo-dark.svg +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/docs/logo.svg +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_async.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_bool.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_class.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_collection.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_core.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_custom_list.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_datetime.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_description.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_dict.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_dyn.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_equals.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_errors.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_extensions.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_extracting.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_fail.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_file.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_in.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_list.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_matchers.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_matchers_phase3.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_none.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_not.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_numbers.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_overloads.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_phase2.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_pipeline.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_readme.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_same_as.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_snapshots.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_soft.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_string.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_structural.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_traceback.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_type.py +0 -0
- {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_warn.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.7
|
|
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
|
|
7
7
|
Project-URL: Issues, https://github.com/Solganis/assertpy2/issues
|
|
8
8
|
Author-email: Justin Shacklette <justin@saturnboy.com>
|
|
9
|
-
Maintainer: Solganis
|
|
9
|
+
Maintainer-email: Solganis <solganis.dev@gmail.com>
|
|
10
10
|
License-Expression: BSD-3-Clause
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Keywords: assert,assert_that,assertion,assertthat,pytest,test,testing,unittest
|
|
@@ -31,6 +31,9 @@ Provides-Extra: allure
|
|
|
31
31
|
Requires-Dist: allure-pytest>=2.13; extra == 'allure'
|
|
32
32
|
Provides-Extra: behave
|
|
33
33
|
Requires-Dist: behave>=1.2.6; extra == 'behave'
|
|
34
|
+
Provides-Extra: json
|
|
35
|
+
Requires-Dist: jsonpath-ng>=1.8; extra == 'json'
|
|
36
|
+
Requires-Dist: jsonschema>=4.0; extra == 'json'
|
|
34
37
|
Description-Content-Type: text/markdown
|
|
35
38
|
|
|
36
39
|
<p align="center">
|
|
@@ -43,7 +46,7 @@ Description-Content-Type: text/markdown
|
|
|
43
46
|
|
|
44
47
|
<p align="center">
|
|
45
48
|
<b>Fluent assertion library for Python with composable matchers, structural matching, and full type safety.</b><br>
|
|
46
|
-
|
|
49
|
+
A modern, batteries-included fork of <a href="https://github.com/assertpy/assertpy">assertpy</a>.
|
|
47
50
|
</p>
|
|
48
51
|
|
|
49
52
|
<p align="center">
|
|
@@ -168,7 +171,8 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
|
168
171
|
- [**Structured errors**](docs/api.md#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
|
|
169
172
|
- **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
|
|
170
173
|
- **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
171
|
-
- [**Soft assertions**](docs/api.md#soft-assertions): thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
|
|
174
|
+
- [**Soft assertions**](docs/api.md#soft-assertions): thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`. Group errors with `sa.group()`, or use `assert_all()` for inline checks.
|
|
175
|
+
- [**JSON assertions**](docs/api.md#json-path--schema-validation): JSONPath navigation (`at_json_path`, `has_json_path`) and JSON Schema validation (`matches_json_schema`).
|
|
172
176
|
- [**Fluent chaining**](docs/api.md#chaining): write assertions as readable one-liners that chain naturally.
|
|
173
177
|
- [**Dynamic assertions**](docs/api.md#objects): `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
|
|
174
178
|
- [**Dict comparison**](docs/api.md#dicts): `is_equal_to()` with `ignore` and `include` for selective key matching.
|
|
@@ -352,6 +356,36 @@ assert_that(items).filtered_on(match.is_positive()).mapped(str).contains("1")
|
|
|
352
356
|
|
|
353
357
|
Available methods: `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
354
358
|
|
|
359
|
+
### Grouped soft assertions
|
|
360
|
+
|
|
361
|
+
```py
|
|
362
|
+
with soft_assertions() as sa:
|
|
363
|
+
with sa.group("Headers"):
|
|
364
|
+
assert_that(headers["Content-Type"]).is_equal_to("application/json")
|
|
365
|
+
with sa.group("Body"):
|
|
366
|
+
assert_that(body["status"]).is_equal_to("ok")
|
|
367
|
+
assert_that(body["items"]).is_not_empty()
|
|
368
|
+
|
|
369
|
+
# or inline with assert_all
|
|
370
|
+
assert_all(
|
|
371
|
+
lambda: assert_that(x).is_positive(),
|
|
372
|
+
lambda: assert_that(y).is_not_none(),
|
|
373
|
+
)
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### JSON path and schema validation
|
|
377
|
+
|
|
378
|
+
Requires `pip install assertpy2[json]`.
|
|
379
|
+
|
|
380
|
+
```py
|
|
381
|
+
data = {"users": [{"name": "Alice"}, {"name": "Bob"}], "meta": {"total": 2}}
|
|
382
|
+
|
|
383
|
+
assert_that(data).at_json_path("$.users[0].name").is_equal_to("Alice")
|
|
384
|
+
assert_that(data).has_json_path("$.meta.total")
|
|
385
|
+
assert_that(data).does_not_have_json_path("$.error")
|
|
386
|
+
assert_that(data).matches_json_schema({"type": "object", "required": ["users"]})
|
|
387
|
+
```
|
|
388
|
+
|
|
355
389
|
### Dict comparison with ignore/include
|
|
356
390
|
|
|
357
391
|
```py
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<b>Fluent assertion library for Python with composable matchers, structural matching, and full type safety.</b><br>
|
|
11
|
-
|
|
11
|
+
A modern, batteries-included fork of <a href="https://github.com/assertpy/assertpy">assertpy</a>.
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
@@ -133,7 +133,8 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
|
133
133
|
- [**Structured errors**](docs/api.md#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
|
|
134
134
|
- **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
|
|
135
135
|
- **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
136
|
-
- [**Soft assertions**](docs/api.md#soft-assertions): thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
|
|
136
|
+
- [**Soft assertions**](docs/api.md#soft-assertions): thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`. Group errors with `sa.group()`, or use `assert_all()` for inline checks.
|
|
137
|
+
- [**JSON assertions**](docs/api.md#json-path--schema-validation): JSONPath navigation (`at_json_path`, `has_json_path`) and JSON Schema validation (`matches_json_schema`).
|
|
137
138
|
- [**Fluent chaining**](docs/api.md#chaining): write assertions as readable one-liners that chain naturally.
|
|
138
139
|
- [**Dynamic assertions**](docs/api.md#objects): `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
|
|
139
140
|
- [**Dict comparison**](docs/api.md#dicts): `is_equal_to()` with `ignore` and `include` for selective key matching.
|
|
@@ -317,6 +318,36 @@ assert_that(items).filtered_on(match.is_positive()).mapped(str).contains("1")
|
|
|
317
318
|
|
|
318
319
|
Available methods: `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
319
320
|
|
|
321
|
+
### Grouped soft assertions
|
|
322
|
+
|
|
323
|
+
```py
|
|
324
|
+
with soft_assertions() as sa:
|
|
325
|
+
with sa.group("Headers"):
|
|
326
|
+
assert_that(headers["Content-Type"]).is_equal_to("application/json")
|
|
327
|
+
with sa.group("Body"):
|
|
328
|
+
assert_that(body["status"]).is_equal_to("ok")
|
|
329
|
+
assert_that(body["items"]).is_not_empty()
|
|
330
|
+
|
|
331
|
+
# or inline with assert_all
|
|
332
|
+
assert_all(
|
|
333
|
+
lambda: assert_that(x).is_positive(),
|
|
334
|
+
lambda: assert_that(y).is_not_none(),
|
|
335
|
+
)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### JSON path and schema validation
|
|
339
|
+
|
|
340
|
+
Requires `pip install assertpy2[json]`.
|
|
341
|
+
|
|
342
|
+
```py
|
|
343
|
+
data = {"users": [{"name": "Alice"}, {"name": "Bob"}], "meta": {"total": 2}}
|
|
344
|
+
|
|
345
|
+
assert_that(data).at_json_path("$.users[0].name").is_equal_to("Alice")
|
|
346
|
+
assert_that(data).has_json_path("$.meta.total")
|
|
347
|
+
assert_that(data).does_not_have_json_path("$.error")
|
|
348
|
+
assert_that(data).matches_json_schema({"type": "object", "required": ["users"]})
|
|
349
|
+
```
|
|
350
|
+
|
|
320
351
|
### Dict comparison with ignore/include
|
|
321
352
|
|
|
322
353
|
```py
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from .assertpy import (
|
|
2
2
|
NegatedBuilder,
|
|
3
|
+
SoftAssertionCollector,
|
|
3
4
|
WarningLoggingAdapter,
|
|
4
5
|
__version__,
|
|
5
6
|
add_extension,
|
|
7
|
+
assert_all,
|
|
6
8
|
assert_that,
|
|
7
9
|
assert_warn,
|
|
8
10
|
fail,
|
|
@@ -22,9 +24,11 @@ __all__ = [
|
|
|
22
24
|
"DiffResult",
|
|
23
25
|
"Matcher",
|
|
24
26
|
"NegatedBuilder",
|
|
27
|
+
"SoftAssertionCollector",
|
|
25
28
|
"WarningLoggingAdapter",
|
|
26
29
|
"__version__",
|
|
27
30
|
"add_extension",
|
|
31
|
+
"assert_all",
|
|
28
32
|
"assert_that",
|
|
29
33
|
"assert_warn",
|
|
30
34
|
"clear_custom_matchers",
|
|
@@ -4,7 +4,8 @@ from typing import TYPE_CHECKING
|
|
|
4
4
|
|
|
5
5
|
if TYPE_CHECKING:
|
|
6
6
|
from collections.abc import Callable
|
|
7
|
-
from
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Protocol
|
|
8
9
|
|
|
9
10
|
from typing_extensions import Self
|
|
10
11
|
|
|
@@ -141,6 +142,12 @@ if TYPE_CHECKING:
|
|
|
141
142
|
def last(self) -> Self: ...
|
|
142
143
|
def element(self, index: int) -> Self: ...
|
|
143
144
|
def single(self) -> Self: ...
|
|
145
|
+
# JsonMixin
|
|
146
|
+
def at_json_path(self, path: str) -> Self: ...
|
|
147
|
+
def has_json_path(self, path: str) -> Self: ...
|
|
148
|
+
def does_not_have_json_path(self, path: str) -> Self: ...
|
|
149
|
+
def matches_json_schema(self, schema: dict[str, Any]) -> Self: ...
|
|
150
|
+
def matches_json_schema_from_file(self, path: str | Path) -> Self: ...
|
|
144
151
|
|
|
145
152
|
class _DictAssertion(_CoreAssertion, Protocol):
|
|
146
153
|
"""Assertions available for ``dict`` values."""
|
|
@@ -162,6 +169,12 @@ if TYPE_CHECKING:
|
|
|
162
169
|
def each(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
163
170
|
# ExtractingMixin
|
|
164
171
|
def extracting(self, *names: object, **kwargs: object) -> Self: ...
|
|
172
|
+
# JsonMixin
|
|
173
|
+
def at_json_path(self, path: str) -> Self: ...
|
|
174
|
+
def has_json_path(self, path: str) -> Self: ...
|
|
175
|
+
def does_not_have_json_path(self, path: str) -> Self: ...
|
|
176
|
+
def matches_json_schema(self, schema: dict[str, Any]) -> Self: ...
|
|
177
|
+
def matches_json_schema_from_file(self, path: str | Path) -> Self: ...
|
|
165
178
|
|
|
166
179
|
class _DateAssertion(_CoreAssertion, Protocol):
|
|
167
180
|
"""Assertions available for ``datetime.date`` and ``datetime.datetime`` values."""
|
|
@@ -40,11 +40,12 @@ from .exception import ExceptionMixin
|
|
|
40
40
|
from .extracting import ExtractingMixin
|
|
41
41
|
from .file import FileMixin
|
|
42
42
|
from .helpers import HelpersMixin
|
|
43
|
+
from .json_mixin import JsonMixin
|
|
43
44
|
from .numeric import NumericMixin
|
|
44
45
|
from .snapshot import SnapshotMixin
|
|
45
46
|
from .string import StringMixin
|
|
46
47
|
|
|
47
|
-
__version__ = "2.3.
|
|
48
|
+
__version__ = "2.3.7"
|
|
48
49
|
|
|
49
50
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
50
51
|
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
@@ -66,6 +67,7 @@ ASSERTPY_FILES = [
|
|
|
66
67
|
"extracting.py",
|
|
67
68
|
"file.py",
|
|
68
69
|
"helpers.py",
|
|
70
|
+
"json_mixin.py",
|
|
69
71
|
"matchers.py",
|
|
70
72
|
"numeric.py",
|
|
71
73
|
"pytest_plugin.py",
|
|
@@ -76,11 +78,52 @@ ASSERTPY_FILES = [
|
|
|
76
78
|
|
|
77
79
|
# soft assertions (contextvars for thread/async safety)
|
|
78
80
|
_soft_ctx: contextvars.ContextVar[int] = contextvars.ContextVar("assertpy2_soft_ctx", default=0)
|
|
79
|
-
_soft_err: contextvars.ContextVar[list[str]] = contextvars.ContextVar("assertpy2_soft_err")
|
|
81
|
+
_soft_err: contextvars.ContextVar[list[tuple[str | None, str]]] = contextvars.ContextVar("assertpy2_soft_err")
|
|
82
|
+
_soft_group: contextvars.ContextVar[str | None] = contextvars.ContextVar("assertpy2_soft_group", default=None)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SoftAssertionCollector:
|
|
86
|
+
"""Collector returned by :func:`soft_assertions` for grouping errors hierarchically."""
|
|
87
|
+
|
|
88
|
+
@contextlib.contextmanager
|
|
89
|
+
def group(self, label: str) -> Iterator[None]:
|
|
90
|
+
"""Group subsequent assertion failures under *label*.
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
Usage::
|
|
94
|
+
|
|
95
|
+
with soft_assertions() as sa:
|
|
96
|
+
with sa.group("Headers"):
|
|
97
|
+
assert_that(headers).contains_key("Content-Type")
|
|
98
|
+
with sa.group("Body"):
|
|
99
|
+
assert_that(body["status"]).is_equal_to("ok")
|
|
100
|
+
"""
|
|
101
|
+
token = _soft_group.set(label)
|
|
102
|
+
try:
|
|
103
|
+
yield
|
|
104
|
+
finally:
|
|
105
|
+
_soft_group.reset(token)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _format_soft_errors(errs: list[tuple[str | None, str]]) -> str:
|
|
109
|
+
has_groups = any(group is not None for group, _ in errs)
|
|
110
|
+
if not has_groups:
|
|
111
|
+
return "soft assertion failures:\n" + "\n".join(f"{i + 1}. {msg}" for i, (_, msg) in enumerate(errs))
|
|
112
|
+
|
|
113
|
+
lines = ["soft assertion failures:"]
|
|
114
|
+
current_group: str | None = object() # ty: ignore[invalid-assignment] # sentinel
|
|
115
|
+
for counter, (group, msg) in enumerate(errs, 1):
|
|
116
|
+
if group != current_group:
|
|
117
|
+
current_group = group
|
|
118
|
+
if group is not None:
|
|
119
|
+
lines.append(f" [{group}]")
|
|
120
|
+
indent = " " if group is not None else " "
|
|
121
|
+
lines.append(f"{indent}{counter}. {msg}")
|
|
122
|
+
return "\n".join(lines)
|
|
80
123
|
|
|
81
124
|
|
|
82
125
|
@contextlib.contextmanager
|
|
83
|
-
def soft_assertions() -> Iterator[
|
|
126
|
+
def soft_assertions() -> Iterator[SoftAssertionCollector]:
|
|
84
127
|
"""Create a soft assertion context.
|
|
85
128
|
|
|
86
129
|
Normally, any assertion failure will halt test execution immediately by raising an error.
|
|
@@ -112,6 +155,14 @@ def soft_assertions() -> Iterator[None]:
|
|
|
112
155
|
4. Expected <foo> to contain only digits, but did not.
|
|
113
156
|
5. Expected <123> to contain only alphabetic chars, but did not.
|
|
114
157
|
|
|
158
|
+
Group errors by section::
|
|
159
|
+
|
|
160
|
+
with soft_assertions() as sa:
|
|
161
|
+
with sa.group("Headers"):
|
|
162
|
+
assert_that(headers["Content-Type"]).is_equal_to("application/json")
|
|
163
|
+
with sa.group("Body"):
|
|
164
|
+
assert_that(body["status"]).is_equal_to("ok")
|
|
165
|
+
|
|
115
166
|
Note:
|
|
116
167
|
The soft assertion context only collects *assertion* failures, other errors such as
|
|
117
168
|
``TypeError`` or ``ValueError`` are always raised immediately. Triggering an explicit test
|
|
@@ -125,17 +176,40 @@ def soft_assertions() -> Iterator[None]:
|
|
|
125
176
|
_soft_ctx.set(ctx + 1)
|
|
126
177
|
|
|
127
178
|
try:
|
|
128
|
-
yield
|
|
179
|
+
yield SoftAssertionCollector()
|
|
129
180
|
finally:
|
|
130
181
|
_soft_ctx.set(_soft_ctx.get() - 1)
|
|
131
182
|
|
|
132
183
|
errs = _soft_err.get([])
|
|
133
184
|
if errs and _soft_ctx.get() == 0:
|
|
134
|
-
out =
|
|
185
|
+
out = _format_soft_errors(errs)
|
|
135
186
|
_soft_err.set([])
|
|
136
187
|
raise AssertionError(out)
|
|
137
188
|
|
|
138
189
|
|
|
190
|
+
def assert_all(*callables: Callable[[], object]) -> None:
|
|
191
|
+
"""Run all callables inside a soft assertion context.
|
|
192
|
+
|
|
193
|
+
A convenience wrapper around :func:`soft_assertions` for inline use.
|
|
194
|
+
|
|
195
|
+
Examples:
|
|
196
|
+
Usage::
|
|
197
|
+
|
|
198
|
+
from assertpy2 import assert_all, assert_that
|
|
199
|
+
|
|
200
|
+
assert_all(
|
|
201
|
+
lambda: assert_that(x).is_positive(),
|
|
202
|
+
lambda: assert_that(y).is_not_none(),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
AssertionError: if any of the callables produce assertion failures
|
|
207
|
+
"""
|
|
208
|
+
with soft_assertions():
|
|
209
|
+
for fn in callables:
|
|
210
|
+
fn()
|
|
211
|
+
|
|
212
|
+
|
|
139
213
|
# factory methods
|
|
140
214
|
|
|
141
215
|
|
|
@@ -303,7 +377,7 @@ def soft_fail(msg=""):
|
|
|
303
377
|
|
|
304
378
|
"""
|
|
305
379
|
if _soft_ctx.get():
|
|
306
|
-
_soft_err.get().append(f"Fail: {msg}!" if msg else "Fail!")
|
|
380
|
+
_soft_err.get().append((_soft_group.get(), f"Fail: {msg}!" if msg else "Fail!"))
|
|
307
381
|
return
|
|
308
382
|
fail(msg)
|
|
309
383
|
|
|
@@ -449,7 +523,7 @@ class NegatedBuilder:
|
|
|
449
523
|
if len(err_list) > before:
|
|
450
524
|
del err_list[before:]
|
|
451
525
|
return self._builder
|
|
452
|
-
err_list.append(self._make_msg(name))
|
|
526
|
+
err_list.append((_soft_group.get(), self._make_msg(name)))
|
|
453
527
|
return self._builder
|
|
454
528
|
|
|
455
529
|
def _negated_warn(
|
|
@@ -470,6 +544,7 @@ class AssertionBuilder(
|
|
|
470
544
|
StringMixin,
|
|
471
545
|
SnapshotMixin,
|
|
472
546
|
NumericMixin,
|
|
547
|
+
JsonMixin,
|
|
473
548
|
HelpersMixin,
|
|
474
549
|
FileMixin,
|
|
475
550
|
ExtractingMixin,
|
|
@@ -550,7 +625,7 @@ class AssertionBuilder(
|
|
|
550
625
|
self.logger.warning(out)
|
|
551
626
|
return self
|
|
552
627
|
elif self.kind == "soft":
|
|
553
|
-
_soft_err.get().append(out)
|
|
628
|
+
_soft_err.get().append((_soft_group.get(), out))
|
|
554
629
|
return self
|
|
555
630
|
else:
|
|
556
631
|
if expected is not None or diff is not None:
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from ._mixin_base import _MixinBase
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from typing_extensions import Self
|
|
11
|
+
|
|
12
|
+
__tracebackhide__ = True
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ensure_jsonpath_ng():
|
|
16
|
+
try:
|
|
17
|
+
import jsonpath_ng.ext # optional dependency
|
|
18
|
+
except ImportError:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"jsonpath-ng is required for JSON path assertions. Install it with: pip install assertpy2[json]"
|
|
21
|
+
) from None
|
|
22
|
+
return jsonpath_ng.ext
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ensure_jsonschema():
|
|
26
|
+
try:
|
|
27
|
+
import jsonschema # optional dependency
|
|
28
|
+
except ImportError:
|
|
29
|
+
raise ImportError(
|
|
30
|
+
"jsonschema is required for JSON schema assertions. Install it with: pip install assertpy2[json]"
|
|
31
|
+
) from None
|
|
32
|
+
return jsonschema
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class JsonMixin(_MixinBase):
|
|
36
|
+
"""JSON path navigation and schema validation mixin."""
|
|
37
|
+
|
|
38
|
+
def at_json_path(self, path: str) -> Self:
|
|
39
|
+
"""Navigate to a JSON path and return a new builder with the matched value.
|
|
40
|
+
|
|
41
|
+
Uses JSONPath syntax (e.g. ``$.users[0].name``). If multiple matches are found,
|
|
42
|
+
the value is a list of all matches. If exactly one match is found, the value is
|
|
43
|
+
unwrapped from the list.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
path: JSONPath expression.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
Usage::
|
|
50
|
+
|
|
51
|
+
data = {"users": [{"name": "Alice"}, {"name": "Bob"}]}
|
|
52
|
+
assert_that(data).at_json_path("$.users[0].name").is_equal_to("Alice")
|
|
53
|
+
assert_that(data).at_json_path("$.users[*].name").is_equal_to(["Alice", "Bob"])
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
AssertionBuilder: a new instance with the extracted value
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: if no match is found at the given path
|
|
60
|
+
"""
|
|
61
|
+
jsonpath_ng_ext = _ensure_jsonpath_ng()
|
|
62
|
+
expr = jsonpath_ng_ext.parse(path)
|
|
63
|
+
matches = expr.find(self.val)
|
|
64
|
+
if not matches:
|
|
65
|
+
raise ValueError(f"Expected JSON path <{path}> to exist, but it did not.")
|
|
66
|
+
if len(matches) == 1:
|
|
67
|
+
return self.builder(matches[0].value, self.description, self.kind)
|
|
68
|
+
return self.builder([m.value for m in matches], self.description, self.kind)
|
|
69
|
+
|
|
70
|
+
def has_json_path(self, path: str) -> Self:
|
|
71
|
+
"""Assert that the given JSON path exists in val.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
path: JSONPath expression.
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
Usage::
|
|
78
|
+
|
|
79
|
+
data = {"meta": {"total": 5}}
|
|
80
|
+
assert_that(data).has_json_path("$.meta.total")
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
AssertionError: if the path does not exist
|
|
87
|
+
"""
|
|
88
|
+
jsonpath_ng_ext = _ensure_jsonpath_ng()
|
|
89
|
+
expr = jsonpath_ng_ext.parse(path)
|
|
90
|
+
matches = expr.find(self.val)
|
|
91
|
+
if not matches:
|
|
92
|
+
return self.error(f"Expected JSON path <{path}> to exist, but it did not.")
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def does_not_have_json_path(self, path: str) -> Self:
|
|
96
|
+
"""Assert that the given JSON path does not exist in val.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
path: JSONPath expression.
|
|
100
|
+
|
|
101
|
+
Examples:
|
|
102
|
+
Usage::
|
|
103
|
+
|
|
104
|
+
data = {"status": "ok"}
|
|
105
|
+
assert_that(data).does_not_have_json_path("$.error")
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
AssertionError: if the path exists
|
|
112
|
+
"""
|
|
113
|
+
jsonpath_ng_ext = _ensure_jsonpath_ng()
|
|
114
|
+
expr = jsonpath_ng_ext.parse(path)
|
|
115
|
+
matches = expr.find(self.val)
|
|
116
|
+
if matches:
|
|
117
|
+
return self.error(f"Expected JSON path <{path}> to not exist, but it did.")
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def matches_json_schema(self, schema: dict[str, Any]) -> Self:
|
|
121
|
+
"""Assert that val conforms to the given JSON Schema.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
schema: a JSON Schema as a dict.
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
Usage::
|
|
128
|
+
|
|
129
|
+
schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
|
|
130
|
+
assert_that({"name": "Alice"}).matches_json_schema(schema)
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
AssertionError: if val does not conform to the schema
|
|
137
|
+
"""
|
|
138
|
+
jsonschema_mod = _ensure_jsonschema()
|
|
139
|
+
try:
|
|
140
|
+
jsonschema_mod.validate(self.val, schema)
|
|
141
|
+
except jsonschema_mod.ValidationError as exc:
|
|
142
|
+
return self.error(f"Expected val to match JSON schema, but validation failed: {exc.message}")
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def matches_json_schema_from_file(self, path: str | Path) -> Self:
|
|
146
|
+
"""Assert that val conforms to a JSON Schema loaded from a file.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
path: path to a JSON file containing the schema.
|
|
150
|
+
|
|
151
|
+
Examples:
|
|
152
|
+
Usage::
|
|
153
|
+
|
|
154
|
+
assert_that(data).matches_json_schema_from_file("schemas/order.json")
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
AssertionError: if val does not conform to the schema
|
|
161
|
+
"""
|
|
162
|
+
schema = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
163
|
+
return self.matches_json_schema(schema)
|
|
@@ -33,6 +33,8 @@ Full API reference for assertpy2. For a quick overview, see the [README](../READ
|
|
|
33
33
|
- [Regex Group Extraction](#regex-group-extraction)
|
|
34
34
|
- [Universal Negation](#universal-negation)
|
|
35
35
|
- [Collection Pipeline](#collection-pipeline)
|
|
36
|
+
- [Grouped Soft Assertions](#grouped-soft-assertions)
|
|
37
|
+
- [JSON Path / Schema Validation](#json-path--schema-validation)
|
|
36
38
|
- [Extension System](#extension-system---adding-custom-assertions)
|
|
37
39
|
- [Chaining](#chaining)
|
|
38
40
|
|
|
@@ -1648,6 +1650,116 @@ assert_that(items).filtered_on(match.is_positive()).not_.is_empty()
|
|
|
1648
1650
|
|
|
1649
1651
|
[Back to top](#table-of-contents)
|
|
1650
1652
|
|
|
1653
|
+
## Grouped Soft Assertions
|
|
1654
|
+
|
|
1655
|
+
Use `soft_assertions() as sa` to get a collector, then `sa.group(label)` to group errors by section:
|
|
1656
|
+
|
|
1657
|
+
```py
|
|
1658
|
+
from assertpy2 import assert_that, soft_assertions
|
|
1659
|
+
|
|
1660
|
+
with soft_assertions() as sa:
|
|
1661
|
+
with sa.group("Headers"):
|
|
1662
|
+
assert_that(headers["Content-Type"]).is_equal_to("application/json")
|
|
1663
|
+
assert_that(headers["Accept"]).contains("json")
|
|
1664
|
+
with sa.group("Body"):
|
|
1665
|
+
assert_that(body["status"]).is_equal_to("ok")
|
|
1666
|
+
assert_that(body["items"]).is_not_empty()
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
Output with groups:
|
|
1670
|
+
|
|
1671
|
+
```
|
|
1672
|
+
soft assertion failures:
|
|
1673
|
+
[Headers]
|
|
1674
|
+
1. Expected <text/html> to be equal to <application/json>, but was not.
|
|
1675
|
+
[Body]
|
|
1676
|
+
2. Expected <error> to be equal to <ok>, but was not.
|
|
1677
|
+
```
|
|
1678
|
+
|
|
1679
|
+
Without `as sa` or without calling `sa.group()`, the behavior is unchanged (flat error list).
|
|
1680
|
+
|
|
1681
|
+
### assert_all
|
|
1682
|
+
|
|
1683
|
+
`assert_all()` is a convenience wrapper for inline soft assertions:
|
|
1684
|
+
|
|
1685
|
+
```py
|
|
1686
|
+
from assertpy2 import assert_all, assert_that
|
|
1687
|
+
|
|
1688
|
+
assert_all(
|
|
1689
|
+
lambda: assert_that(x).is_positive(),
|
|
1690
|
+
lambda: assert_that(y).is_not_none(),
|
|
1691
|
+
lambda: assert_that(z).is_length(3),
|
|
1692
|
+
)
|
|
1693
|
+
```
|
|
1694
|
+
|
|
1695
|
+
Equivalent to wrapping all calls in `with soft_assertions():`.
|
|
1696
|
+
|
|
1697
|
+
[Back to top](#table-of-contents)
|
|
1698
|
+
|
|
1699
|
+
## JSON Path / Schema Validation
|
|
1700
|
+
|
|
1701
|
+
Navigate JSON structures with JSONPath and validate against JSON Schema.
|
|
1702
|
+
|
|
1703
|
+
Requires optional dependencies:
|
|
1704
|
+
|
|
1705
|
+
```bash
|
|
1706
|
+
pip install assertpy2[json]
|
|
1707
|
+
```
|
|
1708
|
+
|
|
1709
|
+
### at_json_path
|
|
1710
|
+
|
|
1711
|
+
Navigate to a JSONPath and continue asserting on the extracted value:
|
|
1712
|
+
|
|
1713
|
+
```py
|
|
1714
|
+
data = {"users": [{"name": "Alice"}, {"name": "Bob"}], "meta": {"total": 2}}
|
|
1715
|
+
|
|
1716
|
+
assert_that(data).at_json_path("$.meta.total").is_equal_to(2)
|
|
1717
|
+
assert_that(data).at_json_path("$.users[0].name").is_equal_to("Alice")
|
|
1718
|
+
assert_that(data).at_json_path("$.users[*].name").is_equal_to(["Alice", "Bob"])
|
|
1719
|
+
```
|
|
1720
|
+
|
|
1721
|
+
Raises `ValueError` if the path does not exist.
|
|
1722
|
+
|
|
1723
|
+
### has_json_path / does_not_have_json_path
|
|
1724
|
+
|
|
1725
|
+
Assert that a JSONPath exists or does not exist:
|
|
1726
|
+
|
|
1727
|
+
```py
|
|
1728
|
+
assert_that(data).has_json_path("$.meta.total")
|
|
1729
|
+
assert_that(data).does_not_have_json_path("$.error")
|
|
1730
|
+
```
|
|
1731
|
+
|
|
1732
|
+
### matches_json_schema
|
|
1733
|
+
|
|
1734
|
+
Validate against a JSON Schema dict:
|
|
1735
|
+
|
|
1736
|
+
```py
|
|
1737
|
+
schema = {
|
|
1738
|
+
"type": "object",
|
|
1739
|
+
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
|
|
1740
|
+
"required": ["name"],
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
assert_that({"name": "Alice", "age": 30}).matches_json_schema(schema)
|
|
1744
|
+
```
|
|
1745
|
+
|
|
1746
|
+
### matches_json_schema_from_file
|
|
1747
|
+
|
|
1748
|
+
Load the schema from a JSON file:
|
|
1749
|
+
|
|
1750
|
+
```py
|
|
1751
|
+
assert_that(data).matches_json_schema_from_file("schemas/user.json")
|
|
1752
|
+
```
|
|
1753
|
+
|
|
1754
|
+
All JSON assertions work with soft assertions and chaining:
|
|
1755
|
+
|
|
1756
|
+
```py
|
|
1757
|
+
with soft_assertions():
|
|
1758
|
+
assert_that(response).has_json_path("$.data").at_json_path("$.data.id").is_positive()
|
|
1759
|
+
```
|
|
1760
|
+
|
|
1761
|
+
[Back to top](#table-of-contents)
|
|
1762
|
+
|
|
1651
1763
|
## Extension System - adding custom assertions
|
|
1652
1764
|
|
|
1653
1765
|
Sometimes you want to add your own custom assertions to `assertpy2`. This can be done using the `add_extension()` helper.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "assertpy2"
|
|
3
|
-
version = "2.3.
|
|
3
|
+
version = "2.3.7"
|
|
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"
|
|
@@ -9,7 +9,7 @@ authors = [
|
|
|
9
9
|
{ name = "Justin Shacklette", email = "justin@saturnboy.com" },
|
|
10
10
|
]
|
|
11
11
|
maintainers = [
|
|
12
|
-
{ name = "Solganis" },
|
|
12
|
+
{ name = "Solganis", email = "solganis.dev@gmail.com" },
|
|
13
13
|
]
|
|
14
14
|
keywords = ["test", "testing", "assert", "assertion", "assertthat", "assert_that", "pytest", "unittest"]
|
|
15
15
|
classifiers = [
|
|
@@ -45,6 +45,7 @@ assertpy2 = "assertpy2.pytest_plugin"
|
|
|
45
45
|
[project.optional-dependencies]
|
|
46
46
|
allure = ["allure-pytest>=2.13"]
|
|
47
47
|
behave = ["behave>=1.2.6"]
|
|
48
|
+
json = ["jsonpath-ng>=1.8", "jsonschema>=4.0"]
|
|
48
49
|
|
|
49
50
|
[build-system]
|
|
50
51
|
requires = ["hatchling"]
|
|
@@ -52,7 +53,7 @@ build-backend = "hatchling.build"
|
|
|
52
53
|
|
|
53
54
|
[dependency-groups]
|
|
54
55
|
dev = [
|
|
55
|
-
"pytest>=9.
|
|
56
|
+
"pytest>=9.1",
|
|
56
57
|
"pytest-cov>=6.1",
|
|
57
58
|
"ruff>=0.15.17",
|
|
58
59
|
"ty>=0.0.49",
|