assertpy2 2.3.2__tar.gz → 2.3.4__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.2 → assertpy2-2.3.4}/PKG-INFO +66 -17
- {assertpy2-2.3.2 → assertpy2-2.3.4}/README.md +65 -16
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/__init__.py +4 -1
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/assertpy.py +1 -1
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/matchers.py +110 -10
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/string.py +90 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/docs/api.md +102 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/pyproject.toml +1 -1
- assertpy2-2.3.4/tests/test_matcher_registry.py +170 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_matchers.py +37 -0
- assertpy2-2.3.4/tests/test_regex_groups.py +118 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_structural.py +24 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/.codecov.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/.github/dependabot.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/.gitignore +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/CONTRIBUTING.md +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/LICENSE +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/SECURITY.md +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/_mixin_base.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/_typing.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/base.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/collection.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/contains.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/date.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/dict.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/errors.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/exception.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/extracting.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/file.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/helpers.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/numeric.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/py.typed +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/docs/logo-dark.svg +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/docs/logo.svg +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_async.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_bool.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_class.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_collection.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_core.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_custom_list.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_datetime.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_description.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_dict.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_dyn.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_equals.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_errors.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_extensions.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_extracting.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_fail.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_file.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_in.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_list.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_matchers_phase3.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_none.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_numbers.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_overloads.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_phase2.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_readme.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_same_as.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_snapshots.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_soft.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_string.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_traceback.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_type.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_warn.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.4}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.4
|
|
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
|
|
@@ -52,9 +52,11 @@ Description-Content-Type: text/markdown
|
|
|
52
52
|
<a href="https://pepy.tech/projects/assertpy2"><img src="https://static.pepy.tech/badge/assertpy2/month" alt="Downloads"></a>
|
|
53
53
|
<a href="https://pypi.org/project/assertpy2/"><img src="https://img.shields.io/pypi/pyversions/assertpy2" alt="Python"></a>
|
|
54
54
|
<a href="https://codecov.io/gh/Solganis/assertpy2"><img src="https://codecov.io/gh/Solganis/assertpy2/graph/badge.svg" alt="Coverage"></a>
|
|
55
|
+
<br>
|
|
56
|
+
<a href="https://github.com/Solganis/assertpy2/blob/main/docs/api.md"><img src="https://img.shields.io/badge/Docs-Read%20The%20Docs-black" alt="Documentation"></a>
|
|
55
57
|
<a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
|
|
56
58
|
<a href="https://github.com/astral-sh/uv"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv"></a>
|
|
57
|
-
<a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/
|
|
59
|
+
<a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json" alt="ty"></a>
|
|
58
60
|
<a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://api.scorecard.dev/projects/github.com/Solganis/assertpy2/badge" alt="OpenSSF Scorecard"></a>
|
|
59
61
|
<a href="https://www.bestpractices.dev/projects/12990"><img src="https://www.bestpractices.dev/projects/12990/badge" alt="OpenSSF Best Practices"></a>
|
|
60
62
|
</p>
|
|
@@ -158,23 +160,25 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
|
158
160
|
|
|
159
161
|
## Features
|
|
160
162
|
|
|
161
|
-
- **Composable matchers
|
|
162
|
-
- **Structural matching
|
|
163
|
-
- **Async assertions
|
|
164
|
-
- **Structured errors
|
|
163
|
+
- [**Composable matchers**](docs/api.md#composable-matchers): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~` operators.
|
|
164
|
+
- [**Structural matching**](docs/api.md#structural-matching): `matches_structure()` for declarative dict/API response validation.
|
|
165
|
+
- [**Async assertions**](docs/api.md#async-assertions): `eventually()` with polling/retry for async and eventual consistency testing.
|
|
166
|
+
- [**Structured errors**](docs/api.md#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
|
|
165
167
|
- **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
|
|
166
168
|
- **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
167
|
-
- **Soft assertions
|
|
168
|
-
- **Fluent chaining
|
|
169
|
-
- **Dynamic assertions
|
|
170
|
-
- **Dict comparison
|
|
171
|
-
- **Extracting
|
|
172
|
-
- **File assertions
|
|
173
|
-
- **Snapshot testing
|
|
174
|
-
- **Allure integration
|
|
175
|
-
- **Behave step matchers
|
|
176
|
-
- **
|
|
177
|
-
-
|
|
169
|
+
- [**Soft assertions**](docs/api.md#soft-assertions): thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
|
|
170
|
+
- [**Fluent chaining**](docs/api.md#chaining): write assertions as readable one-liners that chain naturally.
|
|
171
|
+
- [**Dynamic assertions**](docs/api.md#objects): `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
|
|
172
|
+
- [**Dict comparison**](docs/api.md#dicts): `is_equal_to()` with `ignore` and `include` for selective key matching.
|
|
173
|
+
- [**Extracting**](docs/api.md#objects): flatten collections on attributes with `filter` and `sort` support.
|
|
174
|
+
- [**File assertions**](docs/api.md#files): `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
|
|
175
|
+
- [**Snapshot testing**](docs/api.md#snapshot-testing): store and compare data structures in JSON format, inspired by Jest.
|
|
176
|
+
- [**Allure integration**](docs/api.md#allure-integration): auto-attach structured diff and actual/expected data to Allure reports.
|
|
177
|
+
- [**Behave step matchers**](docs/api.md#behave-step-matchers): ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
|
|
178
|
+
- [**Custom matchers**](docs/api.md#custom-matchers---registering-domain-matchers): register domain-specific matchers via `register_matcher()`, composable with `&`, `|`, `~`.
|
|
179
|
+
- [**Regex group extraction**](docs/api.md#regex-group-extraction): `extracting_group()` and `matches_with_groups()` to assert on regex captures fluently.
|
|
180
|
+
- [**Extensions**](docs/api.md#extension-system---adding-custom-assertions): add custom assertions via `add_extension()`.
|
|
181
|
+
- [Strings](docs/api.md#strings), [numbers](docs/api.md#numbers), [lists](docs/api.md#lists), [tuples](docs/api.md#tuples), [sets](docs/api.md#sets), [dicts](docs/api.md#dicts), [dates](docs/api.md#dates), [booleans](docs/api.md#booleans), [objects](docs/api.md#objects), [exceptions](docs/api.md#failure).
|
|
178
182
|
|
|
179
183
|
|
|
180
184
|
## Composable matchers
|
|
@@ -348,6 +352,51 @@ assert_that(fred).has_shoe_size(12)
|
|
|
348
352
|
assert_that({"a": 1, "b": 2, "c": 3}).snapshot()
|
|
349
353
|
```
|
|
350
354
|
|
|
355
|
+
### Custom matchers
|
|
356
|
+
|
|
357
|
+
Register domain-specific matchers on the `match` namespace with `register_matcher()`:
|
|
358
|
+
|
|
359
|
+
```py
|
|
360
|
+
from assertpy2 import assert_that, match, register_matcher
|
|
361
|
+
|
|
362
|
+
@register_matcher("is_valid_email")
|
|
363
|
+
def is_valid_email():
|
|
364
|
+
return match.matches_regex(r"^[\w.-]+@[\w.-]+\.\w+$")
|
|
365
|
+
|
|
366
|
+
# parametrised matchers
|
|
367
|
+
@register_matcher("has_status")
|
|
368
|
+
def has_status(expected: str):
|
|
369
|
+
return match.has_property("status", match.equal_to(expected))
|
|
370
|
+
|
|
371
|
+
# use everywhere matchers are accepted
|
|
372
|
+
assert_that("alice@example.com").satisfies(match.is_valid_email())
|
|
373
|
+
assert_that(users).extracting("email").each(match.is_valid_email())
|
|
374
|
+
assert_that(data).matches_structure({"email": match.is_valid_email()})
|
|
375
|
+
|
|
376
|
+
# composition works automatically
|
|
377
|
+
assert_that(email).satisfies(match.is_valid_email() & match.contains_string("@company.com"))
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Remove with `unregister_matcher("is_valid_email")`.
|
|
381
|
+
|
|
382
|
+
### Regex group extraction
|
|
383
|
+
|
|
384
|
+
Extract regex groups and continue the fluent chain:
|
|
385
|
+
|
|
386
|
+
```py
|
|
387
|
+
log = "2024-01-15 ERROR status=500 path=/api/users"
|
|
388
|
+
|
|
389
|
+
# extract a positional group
|
|
390
|
+
assert_that(log).extracting_group(r"status=(\d+)", 1).is_equal_to("500")
|
|
391
|
+
|
|
392
|
+
# extract a named group
|
|
393
|
+
assert_that(log).extracting_group(r"(?P<level>\w+) status", "level").is_equal_to("ERROR")
|
|
394
|
+
|
|
395
|
+
# get all groups as a tuple or dict (named groups)
|
|
396
|
+
assert_that("key=value").matches_with_groups(r"(?P<k>\w+)=(?P<v>\w+)") \
|
|
397
|
+
.contains_entry({"k": "key"}).contains_entry({"v": "value"})
|
|
398
|
+
```
|
|
399
|
+
|
|
351
400
|
### Extensions
|
|
352
401
|
|
|
353
402
|
```py
|
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
<a href="https://pepy.tech/projects/assertpy2"><img src="https://static.pepy.tech/badge/assertpy2/month" alt="Downloads"></a>
|
|
18
18
|
<a href="https://pypi.org/project/assertpy2/"><img src="https://img.shields.io/pypi/pyversions/assertpy2" alt="Python"></a>
|
|
19
19
|
<a href="https://codecov.io/gh/Solganis/assertpy2"><img src="https://codecov.io/gh/Solganis/assertpy2/graph/badge.svg" alt="Coverage"></a>
|
|
20
|
+
<br>
|
|
21
|
+
<a href="https://github.com/Solganis/assertpy2/blob/main/docs/api.md"><img src="https://img.shields.io/badge/Docs-Read%20The%20Docs-black" alt="Documentation"></a>
|
|
20
22
|
<a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
|
|
21
23
|
<a href="https://github.com/astral-sh/uv"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv"></a>
|
|
22
|
-
<a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/
|
|
24
|
+
<a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json" alt="ty"></a>
|
|
23
25
|
<a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://api.scorecard.dev/projects/github.com/Solganis/assertpy2/badge" alt="OpenSSF Scorecard"></a>
|
|
24
26
|
<a href="https://www.bestpractices.dev/projects/12990"><img src="https://www.bestpractices.dev/projects/12990/badge" alt="OpenSSF Best Practices"></a>
|
|
25
27
|
</p>
|
|
@@ -123,23 +125,25 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
|
123
125
|
|
|
124
126
|
## Features
|
|
125
127
|
|
|
126
|
-
- **Composable matchers
|
|
127
|
-
- **Structural matching
|
|
128
|
-
- **Async assertions
|
|
129
|
-
- **Structured errors
|
|
128
|
+
- [**Composable matchers**](docs/api.md#composable-matchers): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~` operators.
|
|
129
|
+
- [**Structural matching**](docs/api.md#structural-matching): `matches_structure()` for declarative dict/API response validation.
|
|
130
|
+
- [**Async assertions**](docs/api.md#async-assertions): `eventually()` with polling/retry for async and eventual consistency testing.
|
|
131
|
+
- [**Structured errors**](docs/api.md#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
|
|
130
132
|
- **Typed overloads**: `assert_that()` returns type-specific Protocols, IDE shows only relevant methods per type.
|
|
131
133
|
- **Type safety**: `Self` return types, `py.typed` ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
132
|
-
- **Soft assertions
|
|
133
|
-
- **Fluent chaining
|
|
134
|
-
- **Dynamic assertions
|
|
135
|
-
- **Dict comparison
|
|
136
|
-
- **Extracting
|
|
137
|
-
- **File assertions
|
|
138
|
-
- **Snapshot testing
|
|
139
|
-
- **Allure integration
|
|
140
|
-
- **Behave step matchers
|
|
141
|
-
- **
|
|
142
|
-
-
|
|
134
|
+
- [**Soft assertions**](docs/api.md#soft-assertions): thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
|
|
135
|
+
- [**Fluent chaining**](docs/api.md#chaining): write assertions as readable one-liners that chain naturally.
|
|
136
|
+
- [**Dynamic assertions**](docs/api.md#objects): `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
|
|
137
|
+
- [**Dict comparison**](docs/api.md#dicts): `is_equal_to()` with `ignore` and `include` for selective key matching.
|
|
138
|
+
- [**Extracting**](docs/api.md#objects): flatten collections on attributes with `filter` and `sort` support.
|
|
139
|
+
- [**File assertions**](docs/api.md#files): `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
|
|
140
|
+
- [**Snapshot testing**](docs/api.md#snapshot-testing): store and compare data structures in JSON format, inspired by Jest.
|
|
141
|
+
- [**Allure integration**](docs/api.md#allure-integration): auto-attach structured diff and actual/expected data to Allure reports.
|
|
142
|
+
- [**Behave step matchers**](docs/api.md#behave-step-matchers): ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
|
|
143
|
+
- [**Custom matchers**](docs/api.md#custom-matchers---registering-domain-matchers): register domain-specific matchers via `register_matcher()`, composable with `&`, `|`, `~`.
|
|
144
|
+
- [**Regex group extraction**](docs/api.md#regex-group-extraction): `extracting_group()` and `matches_with_groups()` to assert on regex captures fluently.
|
|
145
|
+
- [**Extensions**](docs/api.md#extension-system---adding-custom-assertions): add custom assertions via `add_extension()`.
|
|
146
|
+
- [Strings](docs/api.md#strings), [numbers](docs/api.md#numbers), [lists](docs/api.md#lists), [tuples](docs/api.md#tuples), [sets](docs/api.md#sets), [dicts](docs/api.md#dicts), [dates](docs/api.md#dates), [booleans](docs/api.md#booleans), [objects](docs/api.md#objects), [exceptions](docs/api.md#failure).
|
|
143
147
|
|
|
144
148
|
|
|
145
149
|
## Composable matchers
|
|
@@ -313,6 +317,51 @@ assert_that(fred).has_shoe_size(12)
|
|
|
313
317
|
assert_that({"a": 1, "b": 2, "c": 3}).snapshot()
|
|
314
318
|
```
|
|
315
319
|
|
|
320
|
+
### Custom matchers
|
|
321
|
+
|
|
322
|
+
Register domain-specific matchers on the `match` namespace with `register_matcher()`:
|
|
323
|
+
|
|
324
|
+
```py
|
|
325
|
+
from assertpy2 import assert_that, match, register_matcher
|
|
326
|
+
|
|
327
|
+
@register_matcher("is_valid_email")
|
|
328
|
+
def is_valid_email():
|
|
329
|
+
return match.matches_regex(r"^[\w.-]+@[\w.-]+\.\w+$")
|
|
330
|
+
|
|
331
|
+
# parametrised matchers
|
|
332
|
+
@register_matcher("has_status")
|
|
333
|
+
def has_status(expected: str):
|
|
334
|
+
return match.has_property("status", match.equal_to(expected))
|
|
335
|
+
|
|
336
|
+
# use everywhere matchers are accepted
|
|
337
|
+
assert_that("alice@example.com").satisfies(match.is_valid_email())
|
|
338
|
+
assert_that(users).extracting("email").each(match.is_valid_email())
|
|
339
|
+
assert_that(data).matches_structure({"email": match.is_valid_email()})
|
|
340
|
+
|
|
341
|
+
# composition works automatically
|
|
342
|
+
assert_that(email).satisfies(match.is_valid_email() & match.contains_string("@company.com"))
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Remove with `unregister_matcher("is_valid_email")`.
|
|
346
|
+
|
|
347
|
+
### Regex group extraction
|
|
348
|
+
|
|
349
|
+
Extract regex groups and continue the fluent chain:
|
|
350
|
+
|
|
351
|
+
```py
|
|
352
|
+
log = "2024-01-15 ERROR status=500 path=/api/users"
|
|
353
|
+
|
|
354
|
+
# extract a positional group
|
|
355
|
+
assert_that(log).extracting_group(r"status=(\d+)", 1).is_equal_to("500")
|
|
356
|
+
|
|
357
|
+
# extract a named group
|
|
358
|
+
assert_that(log).extracting_group(r"(?P<level>\w+) status", "level").is_equal_to("ERROR")
|
|
359
|
+
|
|
360
|
+
# get all groups as a tuple or dict (named groups)
|
|
361
|
+
assert_that("key=value").matches_with_groups(r"(?P<k>\w+)=(?P<v>\w+)") \
|
|
362
|
+
.contains_entry({"k": "key"}).contains_entry({"v": "value"})
|
|
363
|
+
```
|
|
364
|
+
|
|
316
365
|
### Extensions
|
|
317
366
|
|
|
318
367
|
```py
|
|
@@ -12,7 +12,7 @@ from .assertpy import (
|
|
|
12
12
|
from .async_assertions import AsyncAssertionBuilder
|
|
13
13
|
from .errors import AssertionFailure, DiffEntry, DiffResult
|
|
14
14
|
from .file import contents_of
|
|
15
|
-
from .matchers import Matcher, match
|
|
15
|
+
from .matchers import Matcher, clear_custom_matchers, match, register_matcher, unregister_matcher
|
|
16
16
|
|
|
17
17
|
__all__ = [
|
|
18
18
|
"AssertionFailure",
|
|
@@ -25,10 +25,13 @@ __all__ = [
|
|
|
25
25
|
"add_extension",
|
|
26
26
|
"assert_that",
|
|
27
27
|
"assert_warn",
|
|
28
|
+
"clear_custom_matchers",
|
|
28
29
|
"contents_of",
|
|
29
30
|
"fail",
|
|
30
31
|
"match",
|
|
32
|
+
"register_matcher",
|
|
31
33
|
"remove_extension",
|
|
32
34
|
"soft_assertions",
|
|
33
35
|
"soft_fail",
|
|
36
|
+
"unregister_matcher",
|
|
34
37
|
]
|
|
@@ -73,7 +73,7 @@ from .numeric import NumericMixin
|
|
|
73
73
|
from .snapshot import SnapshotMixin
|
|
74
74
|
from .string import StringMixin
|
|
75
75
|
|
|
76
|
-
__version__ = "2.3.
|
|
76
|
+
__version__ = "2.3.4"
|
|
77
77
|
|
|
78
78
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
79
79
|
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
+
import threading
|
|
4
5
|
import uuid as _uuid_mod
|
|
6
|
+
from collections.abc import Callable
|
|
5
7
|
from typing import Any, Protocol, runtime_checkable
|
|
6
8
|
|
|
7
9
|
|
|
@@ -116,7 +118,10 @@ class GreaterThanMatcher(BaseMatcher):
|
|
|
116
118
|
self.boundary = boundary
|
|
117
119
|
|
|
118
120
|
def matches(self, value: Any) -> bool:
|
|
119
|
-
|
|
121
|
+
try:
|
|
122
|
+
return value > self.boundary
|
|
123
|
+
except TypeError:
|
|
124
|
+
return False
|
|
120
125
|
|
|
121
126
|
def describe(self) -> str:
|
|
122
127
|
return f"a value greater than <{self.boundary}>"
|
|
@@ -127,7 +132,10 @@ class GreaterThanOrEqualToMatcher(BaseMatcher):
|
|
|
127
132
|
self.boundary = boundary
|
|
128
133
|
|
|
129
134
|
def matches(self, value: Any) -> bool:
|
|
130
|
-
|
|
135
|
+
try:
|
|
136
|
+
return value >= self.boundary
|
|
137
|
+
except TypeError:
|
|
138
|
+
return False
|
|
131
139
|
|
|
132
140
|
def describe(self) -> str:
|
|
133
141
|
return f"a value greater than or equal to <{self.boundary}>"
|
|
@@ -138,7 +146,10 @@ class LessThanMatcher(BaseMatcher):
|
|
|
138
146
|
self.boundary = boundary
|
|
139
147
|
|
|
140
148
|
def matches(self, value: Any) -> bool:
|
|
141
|
-
|
|
149
|
+
try:
|
|
150
|
+
return value < self.boundary
|
|
151
|
+
except TypeError:
|
|
152
|
+
return False
|
|
142
153
|
|
|
143
154
|
def describe(self) -> str:
|
|
144
155
|
return f"a value less than <{self.boundary}>"
|
|
@@ -149,7 +160,10 @@ class LessThanOrEqualToMatcher(BaseMatcher):
|
|
|
149
160
|
self.boundary = boundary
|
|
150
161
|
|
|
151
162
|
def matches(self, value: Any) -> bool:
|
|
152
|
-
|
|
163
|
+
try:
|
|
164
|
+
return value <= self.boundary
|
|
165
|
+
except TypeError:
|
|
166
|
+
return False
|
|
153
167
|
|
|
154
168
|
def describe(self) -> str:
|
|
155
169
|
return f"a value less than or equal to <{self.boundary}>"
|
|
@@ -161,7 +175,10 @@ class BetweenMatcher(BaseMatcher):
|
|
|
161
175
|
self.high = high
|
|
162
176
|
|
|
163
177
|
def matches(self, value: Any) -> bool:
|
|
164
|
-
|
|
178
|
+
try:
|
|
179
|
+
return self.low <= value <= self.high
|
|
180
|
+
except TypeError:
|
|
181
|
+
return False
|
|
165
182
|
|
|
166
183
|
def describe(self) -> str:
|
|
167
184
|
return f"a value between <{self.low}> and <{self.high}>"
|
|
@@ -173,7 +190,10 @@ class CloseToMatcher(BaseMatcher):
|
|
|
173
190
|
self.tolerance = tolerance
|
|
174
191
|
|
|
175
192
|
def matches(self, value: Any) -> bool:
|
|
176
|
-
|
|
193
|
+
try:
|
|
194
|
+
return abs(value - self.expected) <= self.tolerance
|
|
195
|
+
except TypeError:
|
|
196
|
+
return False
|
|
177
197
|
|
|
178
198
|
def describe(self) -> str:
|
|
179
199
|
return f"a value within <{self.tolerance}> of <{self.expected}>"
|
|
@@ -501,7 +521,7 @@ class StructureMatcher(BaseMatcher):
|
|
|
501
521
|
def matches(self, value: Any) -> bool:
|
|
502
522
|
if not isinstance(value, dict):
|
|
503
523
|
return False
|
|
504
|
-
return self._match_recursive(value, self._spec, "") is None
|
|
524
|
+
return self._match_recursive(value, self._spec, "", set()) is None
|
|
505
525
|
|
|
506
526
|
def describe(self) -> str:
|
|
507
527
|
return f"a dict matching structure {self._describe_spec(self._spec)}"
|
|
@@ -509,12 +529,16 @@ class StructureMatcher(BaseMatcher):
|
|
|
509
529
|
def describe_mismatch(self, value: Any) -> str:
|
|
510
530
|
if not isinstance(value, dict):
|
|
511
531
|
return f"was not a dict: <{value}>"
|
|
512
|
-
error = self._match_recursive(value, self._spec, "")
|
|
532
|
+
error = self._match_recursive(value, self._spec, "", set())
|
|
513
533
|
if error:
|
|
514
534
|
return error
|
|
515
535
|
return f"was <{value}>"
|
|
516
536
|
|
|
517
|
-
def _match_recursive(self, value: dict, spec: dict, path: str) -> str | None:
|
|
537
|
+
def _match_recursive(self, value: dict, spec: dict, path: str, seen: set[tuple[int, int]]) -> str | None:
|
|
538
|
+
pair_id = (id(value), id(spec))
|
|
539
|
+
if pair_id in seen:
|
|
540
|
+
return f"circular reference detected at <{path or 'root'}>"
|
|
541
|
+
seen.add(pair_id)
|
|
518
542
|
for key, expected in spec.items():
|
|
519
543
|
current_path = f"{path}.{key}" if path else str(key)
|
|
520
544
|
if key not in value:
|
|
@@ -528,7 +552,7 @@ class StructureMatcher(BaseMatcher):
|
|
|
528
552
|
elif isinstance(expected, dict):
|
|
529
553
|
if not isinstance(actual, dict):
|
|
530
554
|
return f"at <{current_path}>: expected a dict, but was <{actual}>"
|
|
531
|
-
error = self._match_recursive(actual, expected, current_path)
|
|
555
|
+
error = self._match_recursive(actual, expected, current_path, seen)
|
|
532
556
|
if error:
|
|
533
557
|
return error
|
|
534
558
|
elif actual != expected:
|
|
@@ -547,6 +571,74 @@ class StructureMatcher(BaseMatcher):
|
|
|
547
571
|
return f"{{{', '.join(parts)}}}"
|
|
548
572
|
|
|
549
573
|
|
|
574
|
+
# --- Custom matcher registry ---
|
|
575
|
+
|
|
576
|
+
_custom_matchers: dict[str, Callable[..., BaseMatcher]] = {}
|
|
577
|
+
_custom_matchers_lock = threading.Lock()
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def register_matcher(name: str) -> Callable[[Callable[..., BaseMatcher]], Callable[..., BaseMatcher]]:
|
|
581
|
+
"""Register a custom matcher factory on the ``match`` namespace.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
name: the name to register on ``match`` (e.g. ``"is_valid_email"``)
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
A decorator that registers the wrapped function and returns it unchanged.
|
|
588
|
+
|
|
589
|
+
Examples:
|
|
590
|
+
Register a simple matcher::
|
|
591
|
+
|
|
592
|
+
@register_matcher("is_valid_email")
|
|
593
|
+
def is_valid_email():
|
|
594
|
+
return match.matches_regex(r"^[\\w.-]+@[\\w.-]+\\.\\w+$")
|
|
595
|
+
|
|
596
|
+
assert_that(email).satisfies(match.is_valid_email())
|
|
597
|
+
|
|
598
|
+
Register a parametrised matcher::
|
|
599
|
+
|
|
600
|
+
@register_matcher("has_status")
|
|
601
|
+
def has_status(expected: str):
|
|
602
|
+
return match.has_property("status", match.equal_to(expected))
|
|
603
|
+
|
|
604
|
+
assert_that(order).satisfies(match.has_status("active"))
|
|
605
|
+
"""
|
|
606
|
+
if not isinstance(name, str):
|
|
607
|
+
raise TypeError("name must be a string")
|
|
608
|
+
if not name.isidentifier():
|
|
609
|
+
raise ValueError(f"name must be a valid Python identifier, got {name!r}")
|
|
610
|
+
|
|
611
|
+
def decorator(func: Callable[..., BaseMatcher]) -> Callable[..., BaseMatcher]:
|
|
612
|
+
if not callable(func):
|
|
613
|
+
raise TypeError("func must be callable")
|
|
614
|
+
with _custom_matchers_lock:
|
|
615
|
+
_custom_matchers[name] = func
|
|
616
|
+
return func
|
|
617
|
+
|
|
618
|
+
return decorator
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def unregister_matcher(name: str) -> None:
|
|
622
|
+
"""Remove a previously registered custom matcher.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
name: the matcher name to remove
|
|
626
|
+
|
|
627
|
+
Raises:
|
|
628
|
+
KeyError: if the name is not registered
|
|
629
|
+
"""
|
|
630
|
+
with _custom_matchers_lock:
|
|
631
|
+
if name not in _custom_matchers:
|
|
632
|
+
raise KeyError(f"no custom matcher registered with name {name!r}")
|
|
633
|
+
del _custom_matchers[name]
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def clear_custom_matchers() -> None:
|
|
637
|
+
"""Remove all registered custom matchers."""
|
|
638
|
+
with _custom_matchers_lock:
|
|
639
|
+
_custom_matchers.clear()
|
|
640
|
+
|
|
641
|
+
|
|
550
642
|
# --- Namespace ---
|
|
551
643
|
|
|
552
644
|
|
|
@@ -705,5 +797,13 @@ class _MatchNamespace:
|
|
|
705
797
|
def structure(spec: dict) -> StructureMatcher:
|
|
706
798
|
return StructureMatcher(spec)
|
|
707
799
|
|
|
800
|
+
def __getattr__(self, name: str) -> Callable[..., BaseMatcher]:
|
|
801
|
+
with _custom_matchers_lock:
|
|
802
|
+
try:
|
|
803
|
+
factory = _custom_matchers[name]
|
|
804
|
+
except KeyError:
|
|
805
|
+
raise AttributeError(f"match has no matcher {name!r}") from None
|
|
806
|
+
return factory
|
|
807
|
+
|
|
708
808
|
|
|
709
809
|
match = _MatchNamespace()
|
|
@@ -519,3 +519,93 @@ class StringMixin(_MixinBase):
|
|
|
519
519
|
if not isinstance(self.val, str):
|
|
520
520
|
return self.error(f"Expected <{self.val}> to be unicode, but was <{type(self.val).__name__}>.")
|
|
521
521
|
return self
|
|
522
|
+
|
|
523
|
+
def extracting_group(self, pattern: str, group: int | str = 0) -> Self:
|
|
524
|
+
"""Search val for ``pattern`` and return a new builder whose val is the captured group.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
pattern: the regular expression pattern (must contain at least one group)
|
|
528
|
+
group: the group index (int) or name (str) to extract. Defaults to ``0``
|
|
529
|
+
(the entire match).
|
|
530
|
+
|
|
531
|
+
Examples:
|
|
532
|
+
Usage with positional groups::
|
|
533
|
+
|
|
534
|
+
assert_that("status=200 path=/api").extracting_group(r"status=(\\d+)", 1).is_equal_to("200")
|
|
535
|
+
|
|
536
|
+
Usage with named groups::
|
|
537
|
+
|
|
538
|
+
assert_that("2024-01-15 ERROR").extracting_group(
|
|
539
|
+
r"(?P<level>\\w+)$", "level"
|
|
540
|
+
).is_equal_to("ERROR")
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
AssertionBuilder: a **new** builder whose val is the extracted group string
|
|
544
|
+
|
|
545
|
+
Raises:
|
|
546
|
+
TypeError: if val is not a string or pattern is not a string
|
|
547
|
+
ValueError: if pattern is empty
|
|
548
|
+
AssertionError: if the pattern does not match val or the group does not exist
|
|
549
|
+
"""
|
|
550
|
+
if not isinstance(self.val, str):
|
|
551
|
+
raise TypeError("val is not a string")
|
|
552
|
+
if not isinstance(pattern, str):
|
|
553
|
+
raise TypeError("given pattern arg must be a string")
|
|
554
|
+
if len(pattern) == 0:
|
|
555
|
+
raise ValueError("given pattern arg must not be empty")
|
|
556
|
+
m = re.search(pattern, self.val)
|
|
557
|
+
if m is None:
|
|
558
|
+
return self.error(f"Expected <{self.val}> to match pattern <{pattern}>, but did not.")
|
|
559
|
+
try:
|
|
560
|
+
extracted = m.group(group)
|
|
561
|
+
except IndexError:
|
|
562
|
+
return self.error(f"Expected pattern <{pattern}> to have group <{group}>, but it does not.")
|
|
563
|
+
if extracted is None:
|
|
564
|
+
return self.error(
|
|
565
|
+
f"Expected group <{group}> of pattern <{pattern}> to be matched in <{self.val}>, but it was not."
|
|
566
|
+
)
|
|
567
|
+
return self.builder(extracted, self.description, self.kind)
|
|
568
|
+
|
|
569
|
+
def matches_with_groups(self, pattern: str) -> Self:
|
|
570
|
+
"""Search val for ``pattern`` and return a new builder whose val is the tuple of all groups.
|
|
571
|
+
|
|
572
|
+
If the pattern contains **named** groups, the builder val is a ``dict``
|
|
573
|
+
of ``{name: value}`` for all named groups. Otherwise it is the
|
|
574
|
+
``tuple`` returned by ``Match.groups()``.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
pattern: the regular expression pattern with one or more groups
|
|
578
|
+
|
|
579
|
+
Examples:
|
|
580
|
+
Positional groups::
|
|
581
|
+
|
|
582
|
+
assert_that("2024-01-15 ERROR").matches_with_groups(
|
|
583
|
+
r"(\\d{4}-\\d{2}-\\d{2}) (\\w+)"
|
|
584
|
+
).is_length(2)
|
|
585
|
+
|
|
586
|
+
Named groups::
|
|
587
|
+
|
|
588
|
+
assert_that("status=200").matches_with_groups(
|
|
589
|
+
r"(?P<key>\\w+)=(?P<val>\\w+)"
|
|
590
|
+
).contains_key("key").contains_key("val")
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
AssertionBuilder: a **new** builder whose val is the groups tuple or groupdict
|
|
594
|
+
|
|
595
|
+
Raises:
|
|
596
|
+
TypeError: if val is not a string or pattern is not a string
|
|
597
|
+
ValueError: if pattern is empty
|
|
598
|
+
AssertionError: if the pattern does not match val
|
|
599
|
+
"""
|
|
600
|
+
if not isinstance(self.val, str):
|
|
601
|
+
raise TypeError("val is not a string")
|
|
602
|
+
if not isinstance(pattern, str):
|
|
603
|
+
raise TypeError("given pattern arg must be a string")
|
|
604
|
+
if len(pattern) == 0:
|
|
605
|
+
raise ValueError("given pattern arg must not be empty")
|
|
606
|
+
m = re.search(pattern, self.val)
|
|
607
|
+
if m is None:
|
|
608
|
+
return self.error(f"Expected <{self.val}> to match pattern <{pattern}>, but did not.")
|
|
609
|
+
groupdict = m.groupdict()
|
|
610
|
+
result = groupdict if groupdict else m.groups()
|
|
611
|
+
return self.builder(result, self.description, self.kind)
|