assertpy2 2.3.2__py3-none-any.whl → 2.3.3__py3-none-any.whl

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/__init__.py CHANGED
@@ -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
  ]
assertpy2/assertpy.py CHANGED
@@ -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
assertpy2/matchers.py CHANGED
@@ -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()
assertpy2/string.py CHANGED
@@ -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)
@@ -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
@@ -1,7 +1,7 @@
1
- assertpy2/__init__.py,sha256=qWkj2DcIPGMFANMff8rF9PKMl4a-D-vE1_o1BiKmTEc,703
1
+ assertpy2/__init__.py,sha256=QAaBt_YH4owfEJ3BybdaBLuZlY2IUqs8dW0kWb0e2tA,791
2
2
  assertpy2/_mixin_base.py,sha256=3g_bcRPNjZI6waSfvDcBXlCSuSQu7oc3E8qssJ4bTjU,2485
3
3
  assertpy2/_typing.py,sha256=lHwVYbqdT8KXEd9UM5X40OmoTXY9wX74AXHFMUN42k0,8857
4
- assertpy2/assertpy.py,sha256=SGImQJbEGIqsGYpxza8nlznxSQP52xcNc6jbAgyUhuA,20089
4
+ assertpy2/assertpy.py,sha256=HmGlrfvolnsK6DgVPq-Pfn0eFP6YOkidZJDkF3Mu-6s,20089
5
5
  assertpy2/async_assertions.py,sha256=LXscgWxLTcGT9uz0Z3WlCmMngS0-AdbqRb7b27u36-c,2838
6
6
  assertpy2/base.py,sha256=uz9NXGxwmPS1RFBqOFMS3lOy1UvGehT5gZF4KYoqRs0,25554
7
7
  assertpy2/behave_matchers.py,sha256=UoRqYh8RECJLe8w0k622rcudP95fXku4n7aXBiQxGG0,2292
@@ -15,14 +15,14 @@ assertpy2/exception.py,sha256=I_zAJx-wZtTQkB1a4EhL5sniAqypedQn4FtFQY5G0qk,6752
15
15
  assertpy2/extracting.py,sha256=kYPLCif4_1MUlJNMSid7EcaTz7SuHzPedspk1Jc_ysw,10367
16
16
  assertpy2/file.py,sha256=rVUofKCUOLyPzFQctxwOhF0OZCrx0Evb361Air4JynM,9575
17
17
  assertpy2/helpers.py,sha256=sxj9NmbHsFOsY-FGgyYLHe7dVhwBT6qX9wT6QoYK7TE,13688
18
- assertpy2/matchers.py,sha256=GAf29rQ2q_1u_UaK1c4QTYW4sNcDmLfdoVvlccXC2Qk,20494
18
+ assertpy2/matchers.py,sha256=k94k7i5N_GVnmrfAxegsLGFubHHcw57PFP3zCuNQ-wU,22607
19
19
  assertpy2/numeric.py,sha256=sHiSxbXF3XD9A16dB0gcMxe2bTrtPKJCxe1OLIvjSGY,21950
20
20
  assertpy2/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  assertpy2/pytest_plugin.py,sha256=H68GieYEX9n9Exsgmuo1Y93zOYV1oVPfAhvNuNu8d3M,3390
22
22
  assertpy2/snapshot.py,sha256=ccdkeXXz11bhEL90iL0ohWhHYJA95K85vhs0y6an1ys,9092
23
- assertpy2/string.py,sha256=Xwz1ZYVMMwlf9b-VsSWUF_DuTdEe40yye4J-7B0Sbjg,19826
24
- assertpy2-2.3.2.dist-info/METADATA,sha256=GM_01bU4DWwyr4xV0hJz_J9fcgz4bAqe9FbxynNRdlQ,16617
25
- assertpy2-2.3.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
26
- assertpy2-2.3.2.dist-info/entry_points.txt,sha256=G7urMRSkSAWVlne6Jzwv_5U5I-Ru4IKKEUb1xADEDCY,47
27
- assertpy2-2.3.2.dist-info/licenses/LICENSE,sha256=tugsGV7w81349vK3IDCgB1FqfiqRZMcJiyWY5N55t4A,1564
28
- assertpy2-2.3.2.dist-info/RECORD,,
23
+ assertpy2/string.py,sha256=0Cc_ow28gTHZRqNrz6T8zxhbsyIl1gs1JCamX6jRoV8,23621
24
+ assertpy2-2.3.3.dist-info/METADATA,sha256=sgWV29WffqbQ0QC9o36nPKBn6pizq_Ap8OA5kAOYiNI,18507
25
+ assertpy2-2.3.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
26
+ assertpy2-2.3.3.dist-info/entry_points.txt,sha256=G7urMRSkSAWVlne6Jzwv_5U5I-Ru4IKKEUb1xADEDCY,47
27
+ assertpy2-2.3.3.dist-info/licenses/LICENSE,sha256=tugsGV7w81349vK3IDCgB1FqfiqRZMcJiyWY5N55t4A,1564
28
+ assertpy2-2.3.3.dist-info/RECORD,,