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.
Files changed (82) hide show
  1. {assertpy2-2.3.2 → assertpy2-2.3.3}/PKG-INFO +51 -2
  2. {assertpy2-2.3.2 → assertpy2-2.3.3}/README.md +50 -1
  3. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/__init__.py +3 -1
  4. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/assertpy.py +1 -1
  5. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/matchers.py +67 -0
  6. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/string.py +90 -0
  7. {assertpy2-2.3.2 → assertpy2-2.3.3}/docs/api.md +102 -0
  8. {assertpy2-2.3.2 → assertpy2-2.3.3}/pyproject.toml +1 -1
  9. assertpy2-2.3.3/tests/test_matcher_registry.py +155 -0
  10. assertpy2-2.3.3/tests/test_regex_groups.py +118 -0
  11. {assertpy2-2.3.2 → assertpy2-2.3.3}/.codecov.yml +0 -0
  12. {assertpy2-2.3.2 → assertpy2-2.3.3}/.github/dependabot.yml +0 -0
  13. {assertpy2-2.3.2 → assertpy2-2.3.3}/.github/workflows/ci.yml +0 -0
  14. {assertpy2-2.3.2 → assertpy2-2.3.3}/.github/workflows/codeql.yml +0 -0
  15. {assertpy2-2.3.2 → assertpy2-2.3.3}/.github/workflows/publish.yml +0 -0
  16. {assertpy2-2.3.2 → assertpy2-2.3.3}/.github/workflows/scorecard.yml +0 -0
  17. {assertpy2-2.3.2 → assertpy2-2.3.3}/.gitignore +0 -0
  18. {assertpy2-2.3.2 → assertpy2-2.3.3}/CONTRIBUTING.md +0 -0
  19. {assertpy2-2.3.2 → assertpy2-2.3.3}/LICENSE +0 -0
  20. {assertpy2-2.3.2 → assertpy2-2.3.3}/SECURITY.md +0 -0
  21. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/_mixin_base.py +0 -0
  22. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/_typing.py +0 -0
  23. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/async_assertions.py +0 -0
  24. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/base.py +0 -0
  25. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/behave_matchers.py +0 -0
  26. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/collection.py +0 -0
  27. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/contains.py +0 -0
  28. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/date.py +0 -0
  29. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/dict.py +0 -0
  30. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/dynamic.py +0 -0
  31. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/errors.py +0 -0
  32. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/exception.py +0 -0
  33. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/extracting.py +0 -0
  34. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/file.py +0 -0
  35. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/helpers.py +0 -0
  36. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/numeric.py +0 -0
  37. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/py.typed +0 -0
  38. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/pytest_plugin.py +0 -0
  39. {assertpy2-2.3.2 → assertpy2-2.3.3}/assertpy2/snapshot.py +0 -0
  40. {assertpy2-2.3.2 → assertpy2-2.3.3}/docs/logo-dark.svg +0 -0
  41. {assertpy2-2.3.2 → assertpy2-2.3.3}/docs/logo.svg +0 -0
  42. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_async.py +0 -0
  43. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_behave_matchers.py +0 -0
  44. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_bool.py +0 -0
  45. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_class.py +0 -0
  46. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_collection.py +0 -0
  47. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_core.py +0 -0
  48. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_custom_dict.py +0 -0
  49. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_custom_list.py +0 -0
  50. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_datetime.py +0 -0
  51. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_description.py +0 -0
  52. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_dict.py +0 -0
  53. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_dict_compare.py +0 -0
  54. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_dyn.py +0 -0
  55. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_equals.py +0 -0
  56. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_errors.py +0 -0
  57. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_expected_exception.py +0 -0
  58. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_extensions.py +0 -0
  59. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_extracting.py +0 -0
  60. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_fail.py +0 -0
  61. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_file.py +0 -0
  62. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_in.py +0 -0
  63. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_list.py +0 -0
  64. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_matchers.py +0 -0
  65. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_matchers_phase3.py +0 -0
  66. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_namedtuple.py +0 -0
  67. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_none.py +0 -0
  68. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_numbers.py +0 -0
  69. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_overloads.py +0 -0
  70. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_phase2.py +0 -0
  71. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_pytest_plugin.py +0 -0
  72. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_readme.py +0 -0
  73. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_same_as.py +0 -0
  74. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_snapshots.py +0 -0
  75. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_soft.py +0 -0
  76. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_soft_fail.py +0 -0
  77. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_string.py +0 -0
  78. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_structural.py +0 -0
  79. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_traceback.py +0 -0
  80. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_type.py +0 -0
  81. {assertpy2-2.3.2 → assertpy2-2.3.3}/tests/test_warn.py +0 -0
  82. {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.2
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/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>
@@ -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/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>
@@ -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.2"
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "assertpy2"
3
- version = "2.3.2"
3
+ version = "2.3.3"
4
4
  description = "Fluent assertion library for Python with composable matchers, structural matching, and full type safety"
5
5
  readme = "README.md"
6
6
  license = "BSD-3-Clause"
@@ -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