assertpy2 2.3.2__tar.gz → 2.3.3__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.3}/PKG-INFO +51 -2
- {assertpy2-2.3.2 → assertpy2-2.3.3}/README.md +50 -1
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/__init__.py +3 -1
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/assertpy.py +1 -1
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/matchers.py +67 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/string.py +90 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/docs/api.md +102 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/pyproject.toml +1 -1
- assertpy2-2.3.3/tests/test_matcher_registry.py +155 -0
- assertpy2-2.3.3/tests/test_regex_groups.py +118 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/.codecov.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/.github/dependabot.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/.gitignore +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/CONTRIBUTING.md +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/LICENSE +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/SECURITY.md +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/_mixin_base.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/_typing.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/base.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/collection.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/contains.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/date.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/dict.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/errors.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/exception.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/extracting.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/file.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/helpers.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/numeric.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/py.typed +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/docs/logo-dark.svg +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/docs/logo.svg +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_async.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_bool.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_class.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_collection.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_core.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_custom_list.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_datetime.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_description.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_dict.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_dyn.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_equals.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_errors.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_extensions.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_extracting.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_fail.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_file.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_in.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_list.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_matchers.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_matchers_phase3.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_none.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_numbers.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_overloads.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_phase2.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_readme.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_same_as.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_snapshots.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_soft.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_string.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_structural.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_traceback.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_type.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_warn.py +0 -0
- {assertpy2-2.3.2 → assertpy2-2.3.3}/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.3
|
|
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>
|
|
@@ -173,6 +175,8 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
|
173
175
|
- **Snapshot testing**: store and compare data structures in JSON format, inspired by Jest.
|
|
174
176
|
- **Allure integration**: auto-attach structured diff and actual/expected data to Allure reports.
|
|
175
177
|
- **Behave step matchers**: ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
|
|
178
|
+
- **Custom matchers**: register domain-specific matchers via `register_matcher()`, composable with `&`, `|`, `~`.
|
|
179
|
+
- **Regex group extraction**: `extracting_group()` and `matches_with_groups()` to assert on regex captures fluently.
|
|
176
180
|
- **Extensions**: add custom assertions via `add_extension()`.
|
|
177
181
|
- Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
|
|
178
182
|
|
|
@@ -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>
|
|
@@ -138,6 +140,8 @@ assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
|
138
140
|
- **Snapshot testing**: store and compare data structures in JSON format, inspired by Jest.
|
|
139
141
|
- **Allure integration**: auto-attach structured diff and actual/expected data to Allure reports.
|
|
140
142
|
- **Behave step matchers**: ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
|
|
143
|
+
- **Custom matchers**: register domain-specific matchers via `register_matcher()`, composable with `&`, `|`, `~`.
|
|
144
|
+
- **Regex group extraction**: `extracting_group()` and `matches_with_groups()` to assert on regex captures fluently.
|
|
141
145
|
- **Extensions**: add custom assertions via `add_extension()`.
|
|
142
146
|
- Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
|
|
143
147
|
|
|
@@ -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, match, register_matcher, unregister_matcher
|
|
16
16
|
|
|
17
17
|
__all__ = [
|
|
18
18
|
"AssertionFailure",
|
|
@@ -28,7 +28,9 @@ __all__ = [
|
|
|
28
28
|
"contents_of",
|
|
29
29
|
"fail",
|
|
30
30
|
"match",
|
|
31
|
+
"register_matcher",
|
|
31
32
|
"remove_extension",
|
|
32
33
|
"soft_assertions",
|
|
33
34
|
"soft_fail",
|
|
35
|
+
"unregister_matcher",
|
|
34
36
|
]
|
|
@@ -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.3"
|
|
77
77
|
|
|
78
78
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
79
79
|
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
import uuid as _uuid_mod
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from typing import Any, Protocol, runtime_checkable
|
|
6
7
|
|
|
7
8
|
|
|
@@ -547,6 +548,65 @@ class StructureMatcher(BaseMatcher):
|
|
|
547
548
|
return f"{{{', '.join(parts)}}}"
|
|
548
549
|
|
|
549
550
|
|
|
551
|
+
# --- Custom matcher registry ---
|
|
552
|
+
|
|
553
|
+
_custom_matchers: dict[str, Callable[..., BaseMatcher]] = {}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def register_matcher(name: str) -> Callable[[Callable[..., BaseMatcher]], Callable[..., BaseMatcher]]:
|
|
557
|
+
"""Register a custom matcher factory on the ``match`` namespace.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
name: the name to register on ``match`` (e.g. ``"is_valid_email"``)
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
A decorator that registers the wrapped function and returns it unchanged.
|
|
564
|
+
|
|
565
|
+
Examples:
|
|
566
|
+
Register a simple matcher::
|
|
567
|
+
|
|
568
|
+
@register_matcher("is_valid_email")
|
|
569
|
+
def is_valid_email():
|
|
570
|
+
return match.matches_regex(r"^[\\w.-]+@[\\w.-]+\\.\\w+$")
|
|
571
|
+
|
|
572
|
+
assert_that(email).satisfies(match.is_valid_email())
|
|
573
|
+
|
|
574
|
+
Register a parametrised matcher::
|
|
575
|
+
|
|
576
|
+
@register_matcher("has_status")
|
|
577
|
+
def has_status(expected: str):
|
|
578
|
+
return match.has_property("status", match.equal_to(expected))
|
|
579
|
+
|
|
580
|
+
assert_that(order).satisfies(match.has_status("active"))
|
|
581
|
+
"""
|
|
582
|
+
if not isinstance(name, str):
|
|
583
|
+
raise TypeError("name must be a string")
|
|
584
|
+
if not name.isidentifier():
|
|
585
|
+
raise ValueError(f"name must be a valid Python identifier, got {name!r}")
|
|
586
|
+
|
|
587
|
+
def decorator(func: Callable[..., BaseMatcher]) -> Callable[..., BaseMatcher]:
|
|
588
|
+
if not callable(func):
|
|
589
|
+
raise TypeError("func must be callable")
|
|
590
|
+
_custom_matchers[name] = func
|
|
591
|
+
return func
|
|
592
|
+
|
|
593
|
+
return decorator
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def unregister_matcher(name: str) -> None:
|
|
597
|
+
"""Remove a previously registered custom matcher.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
name: the matcher name to remove
|
|
601
|
+
|
|
602
|
+
Raises:
|
|
603
|
+
KeyError: if the name is not registered
|
|
604
|
+
"""
|
|
605
|
+
if name not in _custom_matchers:
|
|
606
|
+
raise KeyError(f"no custom matcher registered with name {name!r}")
|
|
607
|
+
del _custom_matchers[name]
|
|
608
|
+
|
|
609
|
+
|
|
550
610
|
# --- Namespace ---
|
|
551
611
|
|
|
552
612
|
|
|
@@ -705,5 +765,12 @@ class _MatchNamespace:
|
|
|
705
765
|
def structure(spec: dict) -> StructureMatcher:
|
|
706
766
|
return StructureMatcher(spec)
|
|
707
767
|
|
|
768
|
+
def __getattr__(self, name: str) -> Callable[..., BaseMatcher]:
|
|
769
|
+
try:
|
|
770
|
+
factory = _custom_matchers[name]
|
|
771
|
+
except KeyError:
|
|
772
|
+
raise AttributeError(f"match has no matcher {name!r}") from None
|
|
773
|
+
return factory
|
|
774
|
+
|
|
708
775
|
|
|
709
776
|
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)
|
|
@@ -29,6 +29,8 @@ Full API reference for assertpy2. For a quick overview, see the [README](../READ
|
|
|
29
29
|
- [Snapshot Testing](#snapshot-testing)
|
|
30
30
|
- [Allure Integration](#allure-integration)
|
|
31
31
|
- [Behave Step Matchers](#behave-step-matchers)
|
|
32
|
+
- [Custom Matchers](#custom-matchers---registering-domain-matchers)
|
|
33
|
+
- [Regex Group Extraction](#regex-group-extraction)
|
|
32
34
|
- [Extension System](#extension-system---adding-custom-assertions)
|
|
33
35
|
- [Chaining](#chaining)
|
|
34
36
|
|
|
@@ -1387,6 +1389,106 @@ value = parse_int("42") # 42
|
|
|
1387
1389
|
```
|
|
1388
1390
|
|
|
1389
1391
|
|
|
1392
|
+
[Back to top](#table-of-contents)
|
|
1393
|
+
|
|
1394
|
+
## Custom Matchers - registering domain matchers
|
|
1395
|
+
|
|
1396
|
+
The `register_matcher()` decorator lets you add custom matchers to the `match` namespace. Custom matchers support full composition with `&`, `|`, `~` and work everywhere matchers are accepted: `satisfies()`, `each()`, `matches_structure()`, `contains()`.
|
|
1397
|
+
|
|
1398
|
+
### Simple (no-argument) matchers
|
|
1399
|
+
|
|
1400
|
+
```py
|
|
1401
|
+
from assertpy2 import assert_that, match, register_matcher
|
|
1402
|
+
|
|
1403
|
+
@register_matcher("is_valid_email")
|
|
1404
|
+
def is_valid_email():
|
|
1405
|
+
return match.matches_regex(r"^[\w.-]+@[\w.-]+\.\w+$")
|
|
1406
|
+
|
|
1407
|
+
assert_that("alice@example.com").satisfies(match.is_valid_email())
|
|
1408
|
+
assert_that(users).extracting("email").each(match.is_valid_email())
|
|
1409
|
+
```
|
|
1410
|
+
|
|
1411
|
+
### Parametrised matchers
|
|
1412
|
+
|
|
1413
|
+
```py
|
|
1414
|
+
@register_matcher("has_status")
|
|
1415
|
+
def has_status(expected: str):
|
|
1416
|
+
return match.has_property("status", match.equal_to(expected))
|
|
1417
|
+
|
|
1418
|
+
assert_that(order).satisfies(match.has_status("active"))
|
|
1419
|
+
```
|
|
1420
|
+
|
|
1421
|
+
### Composition
|
|
1422
|
+
|
|
1423
|
+
Custom matchers compose with built-in matchers using `&`, `|`, `~`:
|
|
1424
|
+
|
|
1425
|
+
```py
|
|
1426
|
+
assert_that(email).satisfies(
|
|
1427
|
+
match.is_valid_email() & match.contains_string("@company.com")
|
|
1428
|
+
)
|
|
1429
|
+
assert_that(value).satisfies(~match.is_valid_email())
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
### In structural matching
|
|
1433
|
+
|
|
1434
|
+
```py
|
|
1435
|
+
assert_that(response).matches_structure({
|
|
1436
|
+
"email": match.is_valid_email(),
|
|
1437
|
+
"status": match.has_status("active"),
|
|
1438
|
+
"name": match.is_non_empty_string(),
|
|
1439
|
+
})
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
### Removing a custom matcher
|
|
1443
|
+
|
|
1444
|
+
```py
|
|
1445
|
+
from assertpy2 import unregister_matcher
|
|
1446
|
+
|
|
1447
|
+
unregister_matcher("is_valid_email")
|
|
1448
|
+
```
|
|
1449
|
+
|
|
1450
|
+
[Back to top](#table-of-contents)
|
|
1451
|
+
|
|
1452
|
+
## Regex Group Extraction
|
|
1453
|
+
|
|
1454
|
+
Extract regex capture groups from strings and continue asserting on the extracted value.
|
|
1455
|
+
|
|
1456
|
+
### extracting_group()
|
|
1457
|
+
|
|
1458
|
+
Search val for a regex pattern and return a new builder whose val is the captured group:
|
|
1459
|
+
|
|
1460
|
+
```py
|
|
1461
|
+
log = "2024-01-15 ERROR status=500 path=/api/users"
|
|
1462
|
+
|
|
1463
|
+
# extract by positional group index
|
|
1464
|
+
assert_that(log).extracting_group(r"status=(\d+)", 1).is_equal_to("500")
|
|
1465
|
+
|
|
1466
|
+
# extract by named group
|
|
1467
|
+
assert_that(log).extracting_group(r"(?P<level>\w+) status", "level").is_equal_to("ERROR")
|
|
1468
|
+
|
|
1469
|
+
# default group=0 extracts the entire match
|
|
1470
|
+
assert_that("abc123").extracting_group(r"\d+").is_equal_to("123")
|
|
1471
|
+
|
|
1472
|
+
# chain further assertions on the extracted value
|
|
1473
|
+
assert_that("count=42").extracting_group(r"count=(\d+)", 1).is_digit().is_length(2)
|
|
1474
|
+
```
|
|
1475
|
+
|
|
1476
|
+
### matches_with_groups()
|
|
1477
|
+
|
|
1478
|
+
Search val for a pattern and return a new builder whose val is the tuple of all groups (or a dict for named groups):
|
|
1479
|
+
|
|
1480
|
+
```py
|
|
1481
|
+
# positional groups return a tuple
|
|
1482
|
+
assert_that("2024-01-15 ERROR").matches_with_groups(
|
|
1483
|
+
r"(\d{4}-\d{2}-\d{2}) (\w+)"
|
|
1484
|
+
).is_equal_to(("2024-01-15", "ERROR"))
|
|
1485
|
+
|
|
1486
|
+
# named groups return a dict
|
|
1487
|
+
assert_that("key=value").matches_with_groups(
|
|
1488
|
+
r"(?P<key>\w+)=(?P<val>\w+)"
|
|
1489
|
+
).contains_entry({"key": "key"}).contains_entry({"val": "value"})
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1390
1492
|
[Back to top](#table-of-contents)
|
|
1391
1493
|
|
|
1392
1494
|
## Extension System - adding custom assertions
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from assertpy2 import assert_that, match, register_matcher, unregister_matcher
|
|
6
|
+
from assertpy2.matchers import _custom_matchers
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestRegisterMatcher:
|
|
10
|
+
def teardown_method(self):
|
|
11
|
+
_custom_matchers.clear()
|
|
12
|
+
|
|
13
|
+
def test_simple_matcher(self):
|
|
14
|
+
@register_matcher("is_short")
|
|
15
|
+
def is_short():
|
|
16
|
+
return match.has_length(3)
|
|
17
|
+
|
|
18
|
+
assert_that("foo").satisfies(match.is_short())
|
|
19
|
+
|
|
20
|
+
def test_parametrised_matcher(self):
|
|
21
|
+
@register_matcher("has_status")
|
|
22
|
+
def has_status(expected: str):
|
|
23
|
+
return match.equal_to(expected)
|
|
24
|
+
|
|
25
|
+
assert_that("active").satisfies(match.has_status("active"))
|
|
26
|
+
|
|
27
|
+
def test_parametrised_matcher_failure(self):
|
|
28
|
+
@register_matcher("has_status")
|
|
29
|
+
def has_status(expected: str):
|
|
30
|
+
return match.equal_to(expected)
|
|
31
|
+
|
|
32
|
+
with pytest.raises(AssertionError):
|
|
33
|
+
assert_that("active").satisfies(match.has_status("inactive"))
|
|
34
|
+
|
|
35
|
+
def test_composition_with_builtin(self):
|
|
36
|
+
@register_matcher("is_valid_email")
|
|
37
|
+
def is_valid_email():
|
|
38
|
+
return match.matches_regex(r"^[\w.-]+@[\w.-]+\.\w+$")
|
|
39
|
+
|
|
40
|
+
combined = match.is_valid_email() & match.contains_string("@company.com")
|
|
41
|
+
assert_that("alice@company.com").satisfies(combined)
|
|
42
|
+
|
|
43
|
+
def test_composition_failure(self):
|
|
44
|
+
@register_matcher("is_valid_email")
|
|
45
|
+
def is_valid_email():
|
|
46
|
+
return match.matches_regex(r"^[\w.-]+@[\w.-]+\.\w+$")
|
|
47
|
+
|
|
48
|
+
combined = match.is_valid_email() & match.contains_string("@company.com")
|
|
49
|
+
with pytest.raises(AssertionError):
|
|
50
|
+
assert_that("alice@other.com").satisfies(combined)
|
|
51
|
+
|
|
52
|
+
def test_with_each(self):
|
|
53
|
+
@register_matcher("is_short")
|
|
54
|
+
def is_short():
|
|
55
|
+
return match.has_length(3)
|
|
56
|
+
|
|
57
|
+
assert_that(["foo", "bar", "baz"]).each(match.is_short())
|
|
58
|
+
|
|
59
|
+
def test_with_matches_structure(self):
|
|
60
|
+
@register_matcher("is_valid_name")
|
|
61
|
+
def is_valid_name():
|
|
62
|
+
return match.is_non_empty_string()
|
|
63
|
+
|
|
64
|
+
data = {"name": "Alice", "age": 30}
|
|
65
|
+
assert_that(data).matches_structure(
|
|
66
|
+
{
|
|
67
|
+
"name": match.is_valid_name(),
|
|
68
|
+
"age": match.between(0, 200),
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def test_invert_custom_matcher(self):
|
|
73
|
+
@register_matcher("is_short")
|
|
74
|
+
def is_short():
|
|
75
|
+
return match.has_length(3)
|
|
76
|
+
|
|
77
|
+
inverted = ~match.is_short()
|
|
78
|
+
assert_that("hello").satisfies(inverted)
|
|
79
|
+
|
|
80
|
+
def test_or_custom_matchers(self):
|
|
81
|
+
@register_matcher("is_three")
|
|
82
|
+
def is_three():
|
|
83
|
+
return match.has_length(3)
|
|
84
|
+
|
|
85
|
+
@register_matcher("is_five")
|
|
86
|
+
def is_five():
|
|
87
|
+
return match.has_length(5)
|
|
88
|
+
|
|
89
|
+
combined = match.is_three() | match.is_five()
|
|
90
|
+
assert_that("hello").satisfies(combined)
|
|
91
|
+
assert_that("foo").satisfies(combined)
|
|
92
|
+
|
|
93
|
+
def test_overwrite_registration(self):
|
|
94
|
+
@register_matcher("custom")
|
|
95
|
+
def custom_v1():
|
|
96
|
+
return match.equal_to(1)
|
|
97
|
+
|
|
98
|
+
@register_matcher("custom")
|
|
99
|
+
def custom_v2():
|
|
100
|
+
return match.equal_to(2)
|
|
101
|
+
|
|
102
|
+
assert_that(2).satisfies(match.custom())
|
|
103
|
+
|
|
104
|
+
def test_returns_decorated_function(self):
|
|
105
|
+
@register_matcher("my_matcher")
|
|
106
|
+
def my_matcher():
|
|
107
|
+
return match.is_positive()
|
|
108
|
+
|
|
109
|
+
assert callable(my_matcher)
|
|
110
|
+
result = my_matcher()
|
|
111
|
+
assert result.matches(5)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestRegisterMatcherErrors:
|
|
115
|
+
def teardown_method(self):
|
|
116
|
+
_custom_matchers.clear()
|
|
117
|
+
|
|
118
|
+
def test_name_not_string(self):
|
|
119
|
+
with pytest.raises(TypeError, match="name must be a string"):
|
|
120
|
+
register_matcher(123)
|
|
121
|
+
|
|
122
|
+
def test_name_not_identifier(self):
|
|
123
|
+
with pytest.raises(ValueError, match="must be a valid Python identifier"):
|
|
124
|
+
register_matcher("not-valid")
|
|
125
|
+
|
|
126
|
+
def test_name_not_identifier_spaces(self):
|
|
127
|
+
with pytest.raises(ValueError, match="must be a valid Python identifier"):
|
|
128
|
+
register_matcher("has spaces")
|
|
129
|
+
|
|
130
|
+
def test_func_not_callable(self):
|
|
131
|
+
with pytest.raises(TypeError, match="func must be callable"):
|
|
132
|
+
register_matcher("test")(42)
|
|
133
|
+
|
|
134
|
+
def test_unregistered_name_attribute_error(self):
|
|
135
|
+
with pytest.raises(AttributeError, match="match has no matcher"):
|
|
136
|
+
match.nonexistent_matcher()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestUnregisterMatcher:
|
|
140
|
+
def teardown_method(self):
|
|
141
|
+
_custom_matchers.clear()
|
|
142
|
+
|
|
143
|
+
def test_unregister(self):
|
|
144
|
+
@register_matcher("temp")
|
|
145
|
+
def temp():
|
|
146
|
+
return match.is_positive()
|
|
147
|
+
|
|
148
|
+
assert_that(5).satisfies(match.temp())
|
|
149
|
+
unregister_matcher("temp")
|
|
150
|
+
with pytest.raises(AttributeError):
|
|
151
|
+
match.temp()
|
|
152
|
+
|
|
153
|
+
def test_unregister_unknown_name(self):
|
|
154
|
+
with pytest.raises(KeyError, match="no custom matcher registered"):
|
|
155
|
+
unregister_matcher("nonexistent")
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from assertpy2 import assert_that
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestExtractingGroup:
|
|
9
|
+
def test_positional_group(self):
|
|
10
|
+
assert_that("status=200 path=/api").extracting_group(r"status=(\d+)", 1).is_equal_to("200")
|
|
11
|
+
|
|
12
|
+
def test_named_group(self):
|
|
13
|
+
assert_that("2024-01-15 ERROR msg").extracting_group(r"(?P<level>\w+) msg$", "level").is_equal_to("ERROR")
|
|
14
|
+
|
|
15
|
+
def test_group_zero_entire_match(self):
|
|
16
|
+
assert_that("hello world").extracting_group(r"hello (\w+)", 0).is_equal_to("hello world")
|
|
17
|
+
|
|
18
|
+
def test_default_group_zero(self):
|
|
19
|
+
assert_that("abc123").extracting_group(r"\d+").is_equal_to("123")
|
|
20
|
+
|
|
21
|
+
def test_chaining_after_extraction(self):
|
|
22
|
+
assert_that("count=42").extracting_group(r"count=(\d+)", 1).is_digit().is_length(2)
|
|
23
|
+
|
|
24
|
+
def test_extracts_to_new_builder(self):
|
|
25
|
+
assert_that("code=404 msg=not_found").extracting_group(r"code=(\d+)", 1).starts_with("4").ends_with("4")
|
|
26
|
+
|
|
27
|
+
def test_preserves_description(self):
|
|
28
|
+
with pytest.raises(AssertionError, match=r"\[API check\]"):
|
|
29
|
+
assert_that("status=200").described_as("API check").extracting_group(r"status=(\d+)", 1).is_equal_to("500")
|
|
30
|
+
|
|
31
|
+
def test_no_match_fails(self):
|
|
32
|
+
with pytest.raises(AssertionError, match="to match pattern"):
|
|
33
|
+
assert_that("no numbers here").extracting_group(r"(\d+)", 1)
|
|
34
|
+
|
|
35
|
+
def test_invalid_group_index(self):
|
|
36
|
+
with pytest.raises(AssertionError, match="to have group"):
|
|
37
|
+
assert_that("abc123").extracting_group(r"(\d+)", 5)
|
|
38
|
+
|
|
39
|
+
def test_unmatched_optional_group(self):
|
|
40
|
+
with pytest.raises(AssertionError, match="to be matched"):
|
|
41
|
+
assert_that("hello").extracting_group(r"hello(?:(\d+))?", 1)
|
|
42
|
+
|
|
43
|
+
def test_val_not_string(self):
|
|
44
|
+
with pytest.raises(TypeError, match="val is not a string"):
|
|
45
|
+
assert_that(123).extracting_group(r"\d+", 0)
|
|
46
|
+
|
|
47
|
+
def test_pattern_not_string(self):
|
|
48
|
+
with pytest.raises(TypeError, match="given pattern arg must be a string"):
|
|
49
|
+
assert_that("hello").extracting_group(123, 0)
|
|
50
|
+
|
|
51
|
+
def test_pattern_empty(self):
|
|
52
|
+
with pytest.raises(ValueError, match="given pattern arg must not be empty"):
|
|
53
|
+
assert_that("hello").extracting_group("", 0)
|
|
54
|
+
|
|
55
|
+
def test_multiple_groups(self):
|
|
56
|
+
assert_that("2024-01-15").extracting_group(r"(\d{4})-(\d{2})-(\d{2})", 1).is_equal_to("2024")
|
|
57
|
+
assert_that("2024-01-15").extracting_group(r"(\d{4})-(\d{2})-(\d{2})", 2).is_equal_to("01")
|
|
58
|
+
assert_that("2024-01-15").extracting_group(r"(\d{4})-(\d{2})-(\d{2})", 3).is_equal_to("15")
|
|
59
|
+
|
|
60
|
+
def test_soft_assertions_mode(self):
|
|
61
|
+
from assertpy2 import soft_assertions
|
|
62
|
+
|
|
63
|
+
with pytest.raises(AssertionError, match="soft assertion failures"), soft_assertions():
|
|
64
|
+
assert_that("status=200").extracting_group(r"status=(\d+)", 1).is_equal_to("500")
|
|
65
|
+
assert_that("code=404").extracting_group(r"code=(\d+)", 1).is_equal_to("200")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestMatchesWithGroups:
|
|
69
|
+
def test_positional_groups_tuple(self):
|
|
70
|
+
assert_that("2024-01-15 ERROR").matches_with_groups(r"(\d{4}-\d{2}-\d{2}) (\w+)").is_length(2)
|
|
71
|
+
|
|
72
|
+
def test_positional_groups_values(self):
|
|
73
|
+
assert_that("2024-01-15 ERROR").matches_with_groups(r"(\d{4}-\d{2}-\d{2}) (\w+)").is_equal_to(
|
|
74
|
+
("2024-01-15", "ERROR")
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def test_named_groups_dict(self):
|
|
78
|
+
assert_that("key=value").matches_with_groups(r"(?P<key>\w+)=(?P<val>\w+)").contains_key("key").contains_key(
|
|
79
|
+
"val"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def test_named_groups_values(self):
|
|
83
|
+
result = {"key": "status", "val": "200"}
|
|
84
|
+
assert_that("status=200").matches_with_groups(r"(?P<key>\w+)=(?P<val>\w+)").is_equal_to(result)
|
|
85
|
+
|
|
86
|
+
def test_chaining_on_dict(self):
|
|
87
|
+
assert_that("name=Alice").matches_with_groups(r"(?P<key>\w+)=(?P<val>\w+)").contains_entry(
|
|
88
|
+
{"key": "name"}
|
|
89
|
+
).contains_entry({"val": "Alice"})
|
|
90
|
+
|
|
91
|
+
def test_no_match_fails(self):
|
|
92
|
+
with pytest.raises(AssertionError, match="to match pattern"):
|
|
93
|
+
assert_that("no match").matches_with_groups(r"(\d+)")
|
|
94
|
+
|
|
95
|
+
def test_preserves_description(self):
|
|
96
|
+
with pytest.raises(AssertionError, match=r"\[log parsing\]"):
|
|
97
|
+
assert_that("no match").described_as("log parsing").matches_with_groups(r"(\d+)")
|
|
98
|
+
|
|
99
|
+
def test_val_not_string(self):
|
|
100
|
+
with pytest.raises(TypeError, match="val is not a string"):
|
|
101
|
+
assert_that(42).matches_with_groups(r"(\d+)")
|
|
102
|
+
|
|
103
|
+
def test_pattern_not_string(self):
|
|
104
|
+
with pytest.raises(TypeError, match="given pattern arg must be a string"):
|
|
105
|
+
assert_that("hello").matches_with_groups(42)
|
|
106
|
+
|
|
107
|
+
def test_pattern_empty(self):
|
|
108
|
+
with pytest.raises(ValueError, match="given pattern arg must not be empty"):
|
|
109
|
+
assert_that("hello").matches_with_groups("")
|
|
110
|
+
|
|
111
|
+
def test_single_group(self):
|
|
112
|
+
assert_that("hello123").matches_with_groups(r"(\d+)").is_equal_to(("123",))
|
|
113
|
+
|
|
114
|
+
def test_soft_assertions_mode(self):
|
|
115
|
+
from assertpy2 import soft_assertions
|
|
116
|
+
|
|
117
|
+
with pytest.raises(AssertionError, match="soft assertion failures"), soft_assertions():
|
|
118
|
+
assert_that("a=1").matches_with_groups(r"(?P<k>\w+)=(?P<v>\w+)").contains_entry({"k": "x"})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|