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.
Files changed (82) hide show
  1. {assertpy2-2.3.2 → assertpy2-2.3.4}/PKG-INFO +66 -17
  2. {assertpy2-2.3.2 → assertpy2-2.3.4}/README.md +65 -16
  3. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/__init__.py +4 -1
  4. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/assertpy.py +1 -1
  5. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/matchers.py +110 -10
  6. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/string.py +90 -0
  7. {assertpy2-2.3.2 → assertpy2-2.3.4}/docs/api.md +102 -0
  8. {assertpy2-2.3.2 → assertpy2-2.3.4}/pyproject.toml +1 -1
  9. assertpy2-2.3.4/tests/test_matcher_registry.py +170 -0
  10. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_matchers.py +37 -0
  11. assertpy2-2.3.4/tests/test_regex_groups.py +118 -0
  12. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_structural.py +24 -0
  13. {assertpy2-2.3.2 → assertpy2-2.3.4}/.codecov.yml +0 -0
  14. {assertpy2-2.3.2 → assertpy2-2.3.4}/.github/dependabot.yml +0 -0
  15. {assertpy2-2.3.2 → assertpy2-2.3.4}/.github/workflows/ci.yml +0 -0
  16. {assertpy2-2.3.2 → assertpy2-2.3.4}/.github/workflows/codeql.yml +0 -0
  17. {assertpy2-2.3.2 → assertpy2-2.3.4}/.github/workflows/publish.yml +0 -0
  18. {assertpy2-2.3.2 → assertpy2-2.3.4}/.github/workflows/scorecard.yml +0 -0
  19. {assertpy2-2.3.2 → assertpy2-2.3.4}/.gitignore +0 -0
  20. {assertpy2-2.3.2 → assertpy2-2.3.4}/CONTRIBUTING.md +0 -0
  21. {assertpy2-2.3.2 → assertpy2-2.3.4}/LICENSE +0 -0
  22. {assertpy2-2.3.2 → assertpy2-2.3.4}/SECURITY.md +0 -0
  23. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/_mixin_base.py +0 -0
  24. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/_typing.py +0 -0
  25. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/async_assertions.py +0 -0
  26. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/base.py +0 -0
  27. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/behave_matchers.py +0 -0
  28. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/collection.py +0 -0
  29. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/contains.py +0 -0
  30. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/date.py +0 -0
  31. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/dict.py +0 -0
  32. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/dynamic.py +0 -0
  33. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/errors.py +0 -0
  34. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/exception.py +0 -0
  35. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/extracting.py +0 -0
  36. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/file.py +0 -0
  37. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/helpers.py +0 -0
  38. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/numeric.py +0 -0
  39. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/py.typed +0 -0
  40. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/pytest_plugin.py +0 -0
  41. {assertpy2-2.3.2 → assertpy2-2.3.4}/assertpy2/snapshot.py +0 -0
  42. {assertpy2-2.3.2 → assertpy2-2.3.4}/docs/logo-dark.svg +0 -0
  43. {assertpy2-2.3.2 → assertpy2-2.3.4}/docs/logo.svg +0 -0
  44. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_async.py +0 -0
  45. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_behave_matchers.py +0 -0
  46. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_bool.py +0 -0
  47. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_class.py +0 -0
  48. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_collection.py +0 -0
  49. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_core.py +0 -0
  50. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_custom_dict.py +0 -0
  51. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_custom_list.py +0 -0
  52. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_datetime.py +0 -0
  53. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_description.py +0 -0
  54. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_dict.py +0 -0
  55. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_dict_compare.py +0 -0
  56. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_dyn.py +0 -0
  57. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_equals.py +0 -0
  58. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_errors.py +0 -0
  59. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_expected_exception.py +0 -0
  60. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_extensions.py +0 -0
  61. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_extracting.py +0 -0
  62. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_fail.py +0 -0
  63. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_file.py +0 -0
  64. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_in.py +0 -0
  65. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_list.py +0 -0
  66. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_matchers_phase3.py +0 -0
  67. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_namedtuple.py +0 -0
  68. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_none.py +0 -0
  69. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_numbers.py +0 -0
  70. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_overloads.py +0 -0
  71. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_phase2.py +0 -0
  72. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_pytest_plugin.py +0 -0
  73. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_readme.py +0 -0
  74. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_same_as.py +0 -0
  75. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_snapshots.py +0 -0
  76. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_soft.py +0 -0
  77. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_soft_fail.py +0 -0
  78. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_string.py +0 -0
  79. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_traceback.py +0 -0
  80. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_type.py +0 -0
  81. {assertpy2-2.3.2 → assertpy2-2.3.4}/tests/test_warn.py +0 -0
  82. {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.2
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/badge/type--checker-ty-blue" alt="ty"></a>
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**: `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~` operators.
162
- - **Structural matching**: `matches_structure()` for declarative dict/API response validation.
163
- - **Async assertions**: `eventually()` with polling/retry for async and eventual consistency testing.
164
- - **Structured errors**: `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
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**: thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
168
- - **Fluent chaining**: write assertions as readable one-liners that chain naturally.
169
- - **Dynamic assertions**: `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
170
- - **Dict comparison**: `is_equal_to()` with `ignore` and `include` for selective key matching.
171
- - **Extracting**: flatten collections on attributes with `filter` and `sort` support.
172
- - **File assertions**: `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
173
- - **Snapshot testing**: store and compare data structures in JSON format, inspired by Jest.
174
- - **Allure integration**: auto-attach structured diff and actual/expected data to Allure reports.
175
- - **Behave step matchers**: ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
176
- - **Extensions**: add custom assertions via `add_extension()`.
177
- - Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
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/badge/type--checker-ty-blue" alt="ty"></a>
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**: `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~` operators.
127
- - **Structural matching**: `matches_structure()` for declarative dict/API response validation.
128
- - **Async assertions**: `eventually()` with polling/retry for async and eventual consistency testing.
129
- - **Structured errors**: `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes, pytest plugin with rich diff output.
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**: thread-safe and async-safe via `contextvars`, collect all failures with `soft_assertions()`.
133
- - **Fluent chaining**: write assertions as readable one-liners that chain naturally.
134
- - **Dynamic assertions**: `has_<name>()` for any attribute, property, or zero-argument method on objects and dicts.
135
- - **Dict comparison**: `is_equal_to()` with `ignore` and `include` for selective key matching.
136
- - **Extracting**: flatten collections on attributes with `filter` and `sort` support.
137
- - **File assertions**: `exists()`, `is_file()`, `is_readable()`, `is_writable()`, `is_executable()` with `pathlib.Path` support.
138
- - **Snapshot testing**: store and compare data structures in JSON format, inspired by Jest.
139
- - **Allure integration**: auto-attach structured diff and actual/expected data to Allure reports.
140
- - **Behave step matchers**: ready-made parameter types (`PositiveInt`, `BoolLike`, etc.) for Behave step definitions.
141
- - **Extensions**: add custom assertions via `add_extension()`.
142
- - Strings, numbers, lists, tuples, sets, dicts, dates, booleans, objects, exceptions.
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.2"
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
- return value > self.boundary
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
- return value >= self.boundary
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
- return value < self.boundary
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
- return value <= self.boundary
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
- return self.low <= value <= self.high
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
- return abs(value - self.expected) <= self.tolerance
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)