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.
Files changed (88) hide show
  1. {assertpy2-2.3.6 → assertpy2-2.3.7}/PKG-INFO +38 -4
  2. {assertpy2-2.3.6 → assertpy2-2.3.7}/README.md +33 -2
  3. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/__init__.py +4 -0
  4. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/_typing.py +14 -1
  5. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/assertpy.py +83 -8
  6. assertpy2-2.3.7/assertpy2/json_mixin.py +163 -0
  7. {assertpy2-2.3.6 → assertpy2-2.3.7}/docs/api.md +112 -0
  8. {assertpy2-2.3.6 → assertpy2-2.3.7}/pyproject.toml +4 -3
  9. assertpy2-2.3.7/tests/test_grouped_soft.py +130 -0
  10. assertpy2-2.3.7/tests/test_json.py +169 -0
  11. assertpy2-2.3.7/uv.lock +792 -0
  12. assertpy2-2.3.6/uv.lock +0 -465
  13. {assertpy2-2.3.6 → assertpy2-2.3.7}/.codecov.yml +0 -0
  14. {assertpy2-2.3.6 → assertpy2-2.3.7}/.github/dependabot.yml +0 -0
  15. {assertpy2-2.3.6 → assertpy2-2.3.7}/.github/workflows/ci.yml +0 -0
  16. {assertpy2-2.3.6 → assertpy2-2.3.7}/.github/workflows/codeql.yml +0 -0
  17. {assertpy2-2.3.6 → assertpy2-2.3.7}/.github/workflows/publish.yml +0 -0
  18. {assertpy2-2.3.6 → assertpy2-2.3.7}/.github/workflows/scorecard.yml +0 -0
  19. {assertpy2-2.3.6 → assertpy2-2.3.7}/.gitignore +0 -0
  20. {assertpy2-2.3.6 → assertpy2-2.3.7}/CONTRIBUTING.md +0 -0
  21. {assertpy2-2.3.6 → assertpy2-2.3.7}/LICENSE +0 -0
  22. {assertpy2-2.3.6 → assertpy2-2.3.7}/SECURITY.md +0 -0
  23. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/_mixin_base.py +0 -0
  24. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/async_assertions.py +0 -0
  25. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/base.py +0 -0
  26. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/behave_matchers.py +0 -0
  27. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/collection.py +0 -0
  28. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/contains.py +0 -0
  29. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/date.py +0 -0
  30. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/dict.py +0 -0
  31. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/dynamic.py +0 -0
  32. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/errors.py +0 -0
  33. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/exception.py +0 -0
  34. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/extracting.py +0 -0
  35. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/file.py +0 -0
  36. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/helpers.py +0 -0
  37. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/matchers.py +0 -0
  38. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/numeric.py +0 -0
  39. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/py.typed +0 -0
  40. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/pytest_plugin.py +0 -0
  41. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/snapshot.py +0 -0
  42. {assertpy2-2.3.6 → assertpy2-2.3.7}/assertpy2/string.py +0 -0
  43. {assertpy2-2.3.6 → assertpy2-2.3.7}/docs/logo-dark.svg +0 -0
  44. {assertpy2-2.3.6 → assertpy2-2.3.7}/docs/logo.svg +0 -0
  45. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_async.py +0 -0
  46. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_behave_matchers.py +0 -0
  47. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_bool.py +0 -0
  48. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_class.py +0 -0
  49. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_collection.py +0 -0
  50. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_core.py +0 -0
  51. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_custom_dict.py +0 -0
  52. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_custom_list.py +0 -0
  53. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_datetime.py +0 -0
  54. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_description.py +0 -0
  55. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_dict.py +0 -0
  56. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_dict_compare.py +0 -0
  57. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_dyn.py +0 -0
  58. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_equals.py +0 -0
  59. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_errors.py +0 -0
  60. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_expected_exception.py +0 -0
  61. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_extensions.py +0 -0
  62. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_extracting.py +0 -0
  63. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_fail.py +0 -0
  64. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_file.py +0 -0
  65. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_in.py +0 -0
  66. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_list.py +0 -0
  67. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_matcher_registry.py +0 -0
  68. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_matchers.py +0 -0
  69. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_matchers_phase3.py +0 -0
  70. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_namedtuple.py +0 -0
  71. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_none.py +0 -0
  72. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_not.py +0 -0
  73. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_numbers.py +0 -0
  74. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_overloads.py +0 -0
  75. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_phase2.py +0 -0
  76. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_pipeline.py +0 -0
  77. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_pytest_plugin.py +0 -0
  78. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_readme.py +0 -0
  79. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_regex_groups.py +0 -0
  80. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_same_as.py +0 -0
  81. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_snapshots.py +0 -0
  82. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_soft.py +0 -0
  83. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_soft_fail.py +0 -0
  84. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_string.py +0 -0
  85. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_structural.py +0 -0
  86. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_traceback.py +0 -0
  87. {assertpy2-2.3.6 → assertpy2-2.3.7}/tests/test_type.py +0 -0
  88. {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.6
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
- Maintained fork of <a href="https://github.com/assertpy/assertpy">assertpy</a>.
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
- Maintained fork of <a href="https://github.com/assertpy/assertpy">assertpy</a>.
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 typing import Protocol
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.6"
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[None]:
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 = "soft assertion failures:\n" + "\n".join(f"{i + 1}. {msg}" for i, msg in enumerate(errs))
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.6"
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.0.3",
56
+ "pytest>=9.1",
56
57
  "pytest-cov>=6.1",
57
58
  "ruff>=0.15.17",
58
59
  "ty>=0.0.49",