assertpy2 2.3.5__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.7/CONTRIBUTING.md +50 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/PKG-INFO +72 -20
- {assertpy2-2.3.5 → assertpy2-2.3.7}/README.md +67 -18
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/__init__.py +6 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/_typing.py +26 -1
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/assertpy.py +147 -37
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/base.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/collection.py +151 -29
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/contains.py +0 -29
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/date.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/dict.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/dynamic.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/exception.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/extracting.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/file.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/helpers.py +0 -28
- assertpy2-2.3.7/assertpy2/json_mixin.py +163 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/numeric.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/snapshot.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/string.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/docs/api.md +237 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/pyproject.toml +4 -3
- assertpy2-2.3.7/tests/test_bool.py +35 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_class.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_collection.py +0 -29
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_core.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_custom_dict.py +0 -28
- assertpy2-2.3.7/tests/test_custom_list.py +52 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_datetime.py +0 -28
- assertpy2-2.3.7/tests/test_description.py +35 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_dict.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_dict_compare.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_dyn.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_equals.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_expected_exception.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_extensions.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_extracting.py +0 -28
- assertpy2-2.3.7/tests/test_fail.py +17 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_file.py +0 -28
- assertpy2-2.3.7/tests/test_grouped_soft.py +130 -0
- assertpy2-2.3.7/tests/test_in.py +47 -0
- assertpy2-2.3.7/tests/test_json.py +169 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_list.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_namedtuple.py +0 -28
- assertpy2-2.3.7/tests/test_none.py +29 -0
- assertpy2-2.3.7/tests/test_not.py +150 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_numbers.py +0 -28
- assertpy2-2.3.7/tests/test_pipeline.py +173 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_readme.py +0 -28
- assertpy2-2.3.7/tests/test_same_as.py +44 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_snapshots.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_soft.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_soft_fail.py +0 -28
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_string.py +0 -29
- assertpy2-2.3.7/tests/test_traceback.py +36 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_type.py +0 -28
- assertpy2-2.3.7/tests/test_warn.py +108 -0
- assertpy2-2.3.7/uv.lock +792 -0
- assertpy2-2.3.5/CONTRIBUTING.md +0 -22
- assertpy2-2.3.5/tests/test_bool.py +0 -63
- assertpy2-2.3.5/tests/test_custom_list.py +0 -80
- assertpy2-2.3.5/tests/test_description.py +0 -63
- assertpy2-2.3.5/tests/test_fail.py +0 -45
- assertpy2-2.3.5/tests/test_in.py +0 -75
- assertpy2-2.3.5/tests/test_none.py +0 -57
- assertpy2-2.3.5/tests/test_same_as.py +0 -72
- assertpy2-2.3.5/tests/test_traceback.py +0 -64
- assertpy2-2.3.5/tests/test_warn.py +0 -136
- assertpy2-2.3.5/uv.lock +0 -465
- {assertpy2-2.3.5 → assertpy2-2.3.7}/.codecov.yml +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/.github/dependabot.yml +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/.gitignore +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/LICENSE +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/SECURITY.md +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/_mixin_base.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/errors.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/matchers.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/py.typed +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/docs/logo-dark.svg +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/docs/logo.svg +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_async.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_errors.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_matchers.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_matchers_phase3.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_overloads.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_phase2.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.3.5 → assertpy2-2.3.7}/tests/test_structural.py +0 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Contributions of docs, tests, or code are welcome.
|
|
4
|
+
|
|
5
|
+
## Workflow
|
|
6
|
+
|
|
7
|
+
1. Fork the repo
|
|
8
|
+
2. Clone your fork (`git clone <your_fork_url>`)
|
|
9
|
+
3. Create a branch (`git checkout -b my_branch`)
|
|
10
|
+
4. Install dependencies: `uv sync`
|
|
11
|
+
5. Make your changes
|
|
12
|
+
6. Run the [verification pipeline](#verification-pipeline) and fix any issues
|
|
13
|
+
7. Commit using [Conventional Commits](#commit-style)
|
|
14
|
+
8. Push your branch (`git push origin my_branch`)
|
|
15
|
+
9. Open a [Pull Request](http://github.com/Solganis/assertpy2/pulls)
|
|
16
|
+
|
|
17
|
+
Read more about how pulls work on GitHub's [About pull requests](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests) page.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Python 3.10+
|
|
22
|
+
- [uv](https://docs.astral.sh/uv/) as the package manager
|
|
23
|
+
|
|
24
|
+
## Verification pipeline
|
|
25
|
+
|
|
26
|
+
Run all checks before submitting a PR. Every step must pass.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# lint
|
|
30
|
+
uv run ruff check assertpy2/ tests/
|
|
31
|
+
|
|
32
|
+
# format
|
|
33
|
+
uv run ruff format --check assertpy2/ tests/
|
|
34
|
+
|
|
35
|
+
# type check
|
|
36
|
+
uv run ty check
|
|
37
|
+
|
|
38
|
+
# tests with coverage (must be 100%)
|
|
39
|
+
uv run pytest tests/ -v --cov=assertpy2 --cov-report=term-missing
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
CI requires 100% code coverage.
|
|
43
|
+
|
|
44
|
+
## Commit style
|
|
45
|
+
|
|
46
|
+
Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, etc.
|
|
47
|
+
|
|
48
|
+
## Tests
|
|
49
|
+
|
|
50
|
+
Write tests for every new feature or bug fix. Use `assertpy2` assertions in tests.
|
|
@@ -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">
|
|
@@ -162,11 +165,14 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
|
162
165
|
|
|
163
166
|
- [**Composable matchers**](docs/api.md#composable-matchers): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
|
|
164
167
|
- [**Structural matching**](docs/api.md#structural-matching): `matches_structure()` for declarative dict/API response validation.
|
|
168
|
+
- [**Universal negation**](docs/api.md#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
|
|
169
|
+
- [**Collection pipeline**](docs/api.md#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()` for transforming collections before assertions.
|
|
165
170
|
- [**Async assertions**](docs/api.md#async-assertions): `eventually()` with polling/retry for async and eventual consistency testing.
|
|
166
171
|
- [**Structured errors**](docs/api.md#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
|
|
167
172
|
- **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
|
|
168
173
|
- **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
169
|
-
- [**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`).
|
|
170
176
|
- [**Fluent chaining**](docs/api.md#chaining): write assertions as readable one-liners that chain naturally.
|
|
171
177
|
- [**Dynamic assertions**](docs/api.md#objects): `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
|
|
172
178
|
- [**Dict comparison**](docs/api.md#dicts): `is_equal_to()` with `ignore` and `include` for selective key matching.
|
|
@@ -318,6 +324,68 @@ DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
|
|
|
318
324
|
|
|
319
325
|
## More features
|
|
320
326
|
|
|
327
|
+
### Universal negation
|
|
328
|
+
|
|
329
|
+
Invert any assertion with `.not_`:
|
|
330
|
+
|
|
331
|
+
```py
|
|
332
|
+
assert_that(5).not_.is_none()
|
|
333
|
+
assert_that("abc123").not_.is_alpha()
|
|
334
|
+
assert_that([3, 1, 2]).not_.is_sorted()
|
|
335
|
+
assert_that(value).described_as("check").not_.is_none().is_positive()
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Works with soft assertions and warn mode.
|
|
339
|
+
|
|
340
|
+
### Collection pipeline
|
|
341
|
+
|
|
342
|
+
Transform collections before asserting:
|
|
343
|
+
|
|
344
|
+
```py
|
|
345
|
+
orders = [Order("DONE", 100), Order("FAILED", 50), Order("DONE", 200)]
|
|
346
|
+
|
|
347
|
+
assert_that(orders).filtered_on(lambda o: o.status == "FAILED").is_length(1)
|
|
348
|
+
assert_that(orders).mapped(lambda o: o.total).contains(100, 200)
|
|
349
|
+
assert_that(orders).first().has_status("DONE")
|
|
350
|
+
assert_that(orders).element(1).has_status("FAILED")
|
|
351
|
+
assert_that([42]).single().is_equal_to(42)
|
|
352
|
+
|
|
353
|
+
# chaining pipeline steps
|
|
354
|
+
assert_that(items).filtered_on(match.is_positive()).mapped(str).contains("1")
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Available methods: `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
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
|
+
|
|
321
389
|
### Dict comparison with ignore/include
|
|
322
390
|
|
|
323
391
|
```py
|
|
@@ -492,19 +560,3 @@ See the [comparison table](#comparison) above for feature differences with other
|
|
|
492
560
|
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
|
493
561
|
|
|
494
562
|
|
|
495
|
-
## License
|
|
496
|
-
|
|
497
|
-
All files are licensed under the BSD 3-Clause License as follows:
|
|
498
|
-
|
|
499
|
-
> Copyright (c) 2015-2019, Activision Publishing, Inc.
|
|
500
|
-
> All rights reserved.
|
|
501
|
-
>
|
|
502
|
-
> Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
503
|
-
>
|
|
504
|
-
> 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
505
|
-
>
|
|
506
|
-
> 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
507
|
-
>
|
|
508
|
-
> 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
|
509
|
-
>
|
|
510
|
-
> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -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">
|
|
@@ -127,11 +127,14 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
|
127
127
|
|
|
128
128
|
- [**Composable matchers**](docs/api.md#composable-matchers): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
|
|
129
129
|
- [**Structural matching**](docs/api.md#structural-matching): `matches_structure()` for declarative dict/API response validation.
|
|
130
|
+
- [**Universal negation**](docs/api.md#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
|
|
131
|
+
- [**Collection pipeline**](docs/api.md#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()` for transforming collections before assertions.
|
|
130
132
|
- [**Async assertions**](docs/api.md#async-assertions): `eventually()` with polling/retry for async and eventual consistency testing.
|
|
131
133
|
- [**Structured errors**](docs/api.md#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
|
|
132
134
|
- **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
|
|
133
135
|
- **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
134
|
-
- [**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`).
|
|
135
138
|
- [**Fluent chaining**](docs/api.md#chaining): write assertions as readable one-liners that chain naturally.
|
|
136
139
|
- [**Dynamic assertions**](docs/api.md#objects): `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
|
|
137
140
|
- [**Dict comparison**](docs/api.md#dicts): `is_equal_to()` with `ignore` and `include` for selective key matching.
|
|
@@ -283,6 +286,68 @@ DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
|
|
|
283
286
|
|
|
284
287
|
## More features
|
|
285
288
|
|
|
289
|
+
### Universal negation
|
|
290
|
+
|
|
291
|
+
Invert any assertion with `.not_`:
|
|
292
|
+
|
|
293
|
+
```py
|
|
294
|
+
assert_that(5).not_.is_none()
|
|
295
|
+
assert_that("abc123").not_.is_alpha()
|
|
296
|
+
assert_that([3, 1, 2]).not_.is_sorted()
|
|
297
|
+
assert_that(value).described_as("check").not_.is_none().is_positive()
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Works with soft assertions and warn mode.
|
|
301
|
+
|
|
302
|
+
### Collection pipeline
|
|
303
|
+
|
|
304
|
+
Transform collections before asserting:
|
|
305
|
+
|
|
306
|
+
```py
|
|
307
|
+
orders = [Order("DONE", 100), Order("FAILED", 50), Order("DONE", 200)]
|
|
308
|
+
|
|
309
|
+
assert_that(orders).filtered_on(lambda o: o.status == "FAILED").is_length(1)
|
|
310
|
+
assert_that(orders).mapped(lambda o: o.total).contains(100, 200)
|
|
311
|
+
assert_that(orders).first().has_status("DONE")
|
|
312
|
+
assert_that(orders).element(1).has_status("FAILED")
|
|
313
|
+
assert_that([42]).single().is_equal_to(42)
|
|
314
|
+
|
|
315
|
+
# chaining pipeline steps
|
|
316
|
+
assert_that(items).filtered_on(match.is_positive()).mapped(str).contains("1")
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Available methods: `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
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
|
+
|
|
286
351
|
### Dict comparison with ignore/include
|
|
287
352
|
|
|
288
353
|
```py
|
|
@@ -457,19 +522,3 @@ See the [comparison table](#comparison) above for feature differences with other
|
|
|
457
522
|
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
|
458
523
|
|
|
459
524
|
|
|
460
|
-
## License
|
|
461
|
-
|
|
462
|
-
All files are licensed under the BSD 3-Clause License as follows:
|
|
463
|
-
|
|
464
|
-
> Copyright (c) 2015-2019, Activision Publishing, Inc.
|
|
465
|
-
> All rights reserved.
|
|
466
|
-
>
|
|
467
|
-
> Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
468
|
-
>
|
|
469
|
-
> 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
470
|
-
>
|
|
471
|
-
> 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
472
|
-
>
|
|
473
|
-
> 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
|
474
|
-
>
|
|
475
|
-
> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
from .assertpy import (
|
|
2
|
+
NegatedBuilder,
|
|
3
|
+
SoftAssertionCollector,
|
|
2
4
|
WarningLoggingAdapter,
|
|
3
5
|
__version__,
|
|
4
6
|
add_extension,
|
|
7
|
+
assert_all,
|
|
5
8
|
assert_that,
|
|
6
9
|
assert_warn,
|
|
7
10
|
fail,
|
|
@@ -20,9 +23,12 @@ __all__ = [
|
|
|
20
23
|
"DiffEntry",
|
|
21
24
|
"DiffResult",
|
|
22
25
|
"Matcher",
|
|
26
|
+
"NegatedBuilder",
|
|
27
|
+
"SoftAssertionCollector",
|
|
23
28
|
"WarningLoggingAdapter",
|
|
24
29
|
"__version__",
|
|
25
30
|
"add_extension",
|
|
31
|
+
"assert_all",
|
|
26
32
|
"assert_that",
|
|
27
33
|
"assert_warn",
|
|
28
34
|
"clear_custom_matchers",
|
|
@@ -4,10 +4,12 @@ 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
|
|
|
12
|
+
from .assertpy import NegatedBuilder
|
|
11
13
|
from .async_assertions import AsyncAssertionBuilder
|
|
12
14
|
from .matchers import Matcher
|
|
13
15
|
|
|
@@ -35,6 +37,9 @@ if TYPE_CHECKING:
|
|
|
35
37
|
def is_not_in(self, *items: object) -> Self: ...
|
|
36
38
|
# SnapshotMixin
|
|
37
39
|
def snapshot(self, id: str | None = ..., path: str = ...) -> Self: ...
|
|
40
|
+
# NegatedBuilder
|
|
41
|
+
@property
|
|
42
|
+
def not_(self) -> NegatedBuilder: ...
|
|
38
43
|
|
|
39
44
|
class _StringAssertion(_CoreAssertion, Protocol):
|
|
40
45
|
"""Assertions available for ``str`` values."""
|
|
@@ -129,6 +134,20 @@ if TYPE_CHECKING:
|
|
|
129
134
|
def any_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
130
135
|
def all_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
131
136
|
def none_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
137
|
+
# CollectionMixin - pipeline
|
|
138
|
+
def filtered_on(self, predicate: Callable[..., bool]) -> Self: ...
|
|
139
|
+
def mapped(self, func: Callable[..., object]) -> Self: ...
|
|
140
|
+
def flat_mapped(self, func: Callable[..., object]) -> Self: ...
|
|
141
|
+
def first(self) -> Self: ...
|
|
142
|
+
def last(self) -> Self: ...
|
|
143
|
+
def element(self, index: int) -> Self: ...
|
|
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: ...
|
|
132
151
|
|
|
133
152
|
class _DictAssertion(_CoreAssertion, Protocol):
|
|
134
153
|
"""Assertions available for ``dict`` values."""
|
|
@@ -150,6 +169,12 @@ if TYPE_CHECKING:
|
|
|
150
169
|
def each(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
151
170
|
# ExtractingMixin
|
|
152
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: ...
|
|
153
178
|
|
|
154
179
|
class _DateAssertion(_CoreAssertion, Protocol):
|
|
155
180
|
"""Assertions available for ``datetime.date`` and ``datetime.datetime`` values."""
|
|
@@ -1,31 +1,3 @@
|
|
|
1
|
-
# Copyright (c) 2015-2019, Activision Publishing, Inc.
|
|
2
|
-
# All rights reserved.
|
|
3
|
-
#
|
|
4
|
-
# Redistribution and use in source and binary forms, with or without modification,
|
|
5
|
-
# are permitted provided that the following conditions are met:
|
|
6
|
-
#
|
|
7
|
-
# 1. Redistributions of source code must retain the above copyright notice, this
|
|
8
|
-
# list of conditions and the following disclaimer.
|
|
9
|
-
#
|
|
10
|
-
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
11
|
-
# this list of conditions and the following disclaimer in the documentation
|
|
12
|
-
# and/or other materials provided with the distribution.
|
|
13
|
-
#
|
|
14
|
-
# 3. Neither the name of the copyright holder nor the names of its contributors
|
|
15
|
-
# may be used to endorse or promote products derived from this software without
|
|
16
|
-
# specific prior written permission.
|
|
17
|
-
#
|
|
18
|
-
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
19
|
-
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
20
|
-
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
21
|
-
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
|
22
|
-
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
23
|
-
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
24
|
-
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
25
|
-
# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
26
|
-
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
27
|
-
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
28
|
-
|
|
29
1
|
"""Assertion library for python unit testing with a fluent API"""
|
|
30
2
|
|
|
31
3
|
from __future__ import annotations
|
|
@@ -37,13 +9,12 @@ import logging
|
|
|
37
9
|
import os
|
|
38
10
|
import sys
|
|
39
11
|
import types
|
|
40
|
-
from collections.abc import Iterator
|
|
12
|
+
from collections.abc import Callable, Iterator
|
|
41
13
|
from typing import TYPE_CHECKING, overload
|
|
42
14
|
|
|
43
15
|
if TYPE_CHECKING:
|
|
44
16
|
import datetime
|
|
45
17
|
import pathlib
|
|
46
|
-
from collections.abc import Callable
|
|
47
18
|
|
|
48
19
|
from typing_extensions import Self
|
|
49
20
|
|
|
@@ -69,11 +40,12 @@ from .exception import ExceptionMixin
|
|
|
69
40
|
from .extracting import ExtractingMixin
|
|
70
41
|
from .file import FileMixin
|
|
71
42
|
from .helpers import HelpersMixin
|
|
43
|
+
from .json_mixin import JsonMixin
|
|
72
44
|
from .numeric import NumericMixin
|
|
73
45
|
from .snapshot import SnapshotMixin
|
|
74
46
|
from .string import StringMixin
|
|
75
47
|
|
|
76
|
-
__version__ = "2.3.
|
|
48
|
+
__version__ = "2.3.7"
|
|
77
49
|
|
|
78
50
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
79
51
|
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
@@ -95,6 +67,7 @@ ASSERTPY_FILES = [
|
|
|
95
67
|
"extracting.py",
|
|
96
68
|
"file.py",
|
|
97
69
|
"helpers.py",
|
|
70
|
+
"json_mixin.py",
|
|
98
71
|
"matchers.py",
|
|
99
72
|
"numeric.py",
|
|
100
73
|
"pytest_plugin.py",
|
|
@@ -105,11 +78,52 @@ ASSERTPY_FILES = [
|
|
|
105
78
|
|
|
106
79
|
# soft assertions (contextvars for thread/async safety)
|
|
107
80
|
_soft_ctx: contextvars.ContextVar[int] = contextvars.ContextVar("assertpy2_soft_ctx", default=0)
|
|
108
|
-
_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)
|
|
109
123
|
|
|
110
124
|
|
|
111
125
|
@contextlib.contextmanager
|
|
112
|
-
def soft_assertions() -> Iterator[
|
|
126
|
+
def soft_assertions() -> Iterator[SoftAssertionCollector]:
|
|
113
127
|
"""Create a soft assertion context.
|
|
114
128
|
|
|
115
129
|
Normally, any assertion failure will halt test execution immediately by raising an error.
|
|
@@ -141,6 +155,14 @@ def soft_assertions() -> Iterator[None]:
|
|
|
141
155
|
4. Expected <foo> to contain only digits, but did not.
|
|
142
156
|
5. Expected <123> to contain only alphabetic chars, but did not.
|
|
143
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
|
+
|
|
144
166
|
Note:
|
|
145
167
|
The soft assertion context only collects *assertion* failures, other errors such as
|
|
146
168
|
``TypeError`` or ``ValueError`` are always raised immediately. Triggering an explicit test
|
|
@@ -154,17 +176,40 @@ def soft_assertions() -> Iterator[None]:
|
|
|
154
176
|
_soft_ctx.set(ctx + 1)
|
|
155
177
|
|
|
156
178
|
try:
|
|
157
|
-
yield
|
|
179
|
+
yield SoftAssertionCollector()
|
|
158
180
|
finally:
|
|
159
181
|
_soft_ctx.set(_soft_ctx.get() - 1)
|
|
160
182
|
|
|
161
183
|
errs = _soft_err.get([])
|
|
162
184
|
if errs and _soft_ctx.get() == 0:
|
|
163
|
-
out =
|
|
185
|
+
out = _format_soft_errors(errs)
|
|
164
186
|
_soft_err.set([])
|
|
165
187
|
raise AssertionError(out)
|
|
166
188
|
|
|
167
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
|
+
|
|
168
213
|
# factory methods
|
|
169
214
|
|
|
170
215
|
|
|
@@ -332,7 +377,7 @@ def soft_fail(msg=""):
|
|
|
332
377
|
|
|
333
378
|
"""
|
|
334
379
|
if _soft_ctx.get():
|
|
335
|
-
_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!"))
|
|
336
381
|
return
|
|
337
382
|
fail(msg)
|
|
338
383
|
|
|
@@ -436,10 +481,70 @@ _logger.addHandler(_handler)
|
|
|
436
481
|
_default_logger = WarningLoggingAdapter(_logger, None)
|
|
437
482
|
|
|
438
483
|
|
|
484
|
+
class NegatedBuilder:
|
|
485
|
+
"""Proxy that inverts the next assertion. Created by ``assert_that(val).not_``."""
|
|
486
|
+
|
|
487
|
+
def __init__(self, builder: AssertionBuilder) -> None:
|
|
488
|
+
self._builder = builder
|
|
489
|
+
|
|
490
|
+
def __getattr__(self, name: str) -> object:
|
|
491
|
+
attr = getattr(self._builder, name)
|
|
492
|
+
if not callable(attr):
|
|
493
|
+
return attr
|
|
494
|
+
|
|
495
|
+
def _negated(*args: object, **kwargs: object) -> AssertionBuilder:
|
|
496
|
+
if self._builder.kind == "soft":
|
|
497
|
+
return self._negated_soft(name, attr, *args, **kwargs)
|
|
498
|
+
if self._builder.kind == "warn":
|
|
499
|
+
return self._negated_warn(name, attr, *args, **kwargs)
|
|
500
|
+
return self._negated_strict(name, attr, *args, **kwargs)
|
|
501
|
+
|
|
502
|
+
return _negated
|
|
503
|
+
|
|
504
|
+
def _make_msg(self, name: str) -> str:
|
|
505
|
+
desc = f"[{self._builder.description}] " if self._builder.description else ""
|
|
506
|
+
return f"{desc}Expected <{self._builder.val}> to NOT satisfy: {name}()"
|
|
507
|
+
|
|
508
|
+
def _negated_strict(
|
|
509
|
+
self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
|
|
510
|
+
) -> AssertionBuilder:
|
|
511
|
+
try:
|
|
512
|
+
attr(*args, **kwargs)
|
|
513
|
+
except (AssertionError, AssertionFailure):
|
|
514
|
+
return self._builder
|
|
515
|
+
raise AssertionError(self._make_msg(name))
|
|
516
|
+
|
|
517
|
+
def _negated_soft(
|
|
518
|
+
self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
|
|
519
|
+
) -> AssertionBuilder:
|
|
520
|
+
err_list = _soft_err.get()
|
|
521
|
+
before = len(err_list)
|
|
522
|
+
attr(*args, **kwargs)
|
|
523
|
+
if len(err_list) > before:
|
|
524
|
+
del err_list[before:]
|
|
525
|
+
return self._builder
|
|
526
|
+
err_list.append((_soft_group.get(), self._make_msg(name)))
|
|
527
|
+
return self._builder
|
|
528
|
+
|
|
529
|
+
def _negated_warn(
|
|
530
|
+
self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
|
|
531
|
+
) -> AssertionBuilder:
|
|
532
|
+
self._builder.kind = None
|
|
533
|
+
try:
|
|
534
|
+
attr(*args, **kwargs)
|
|
535
|
+
except (AssertionError, AssertionFailure):
|
|
536
|
+
return self._builder
|
|
537
|
+
finally:
|
|
538
|
+
self._builder.kind = "warn"
|
|
539
|
+
self._builder.logger.warning(self._make_msg(name))
|
|
540
|
+
return self._builder
|
|
541
|
+
|
|
542
|
+
|
|
439
543
|
class AssertionBuilder(
|
|
440
544
|
StringMixin,
|
|
441
545
|
SnapshotMixin,
|
|
442
546
|
NumericMixin,
|
|
547
|
+
JsonMixin,
|
|
443
548
|
HelpersMixin,
|
|
444
549
|
FileMixin,
|
|
445
550
|
ExtractingMixin,
|
|
@@ -474,6 +579,11 @@ class AssertionBuilder(
|
|
|
474
579
|
self.logger = logger if logger else _default_logger
|
|
475
580
|
self._not_expected = False
|
|
476
581
|
|
|
582
|
+
@property
|
|
583
|
+
def not_(self) -> NegatedBuilder:
|
|
584
|
+
"""Invert the next assertion in the chain."""
|
|
585
|
+
return NegatedBuilder(self)
|
|
586
|
+
|
|
477
587
|
def builder(self, val, description="", kind=None, expected=None, logger=None):
|
|
478
588
|
"""Helper to build a new :class:`AssertionBuilder` instance. Use this only if not chaining to ``self``.
|
|
479
589
|
|
|
@@ -515,7 +625,7 @@ class AssertionBuilder(
|
|
|
515
625
|
self.logger.warning(out)
|
|
516
626
|
return self
|
|
517
627
|
elif self.kind == "soft":
|
|
518
|
-
_soft_err.get().append(out)
|
|
628
|
+
_soft_err.get().append((_soft_group.get(), out))
|
|
519
629
|
return self
|
|
520
630
|
else:
|
|
521
631
|
if expected is not None or diff is not None:
|