Repository: scravy/awesome-pattern-matching (language: python)

Issue:
Multimatch captures in Patterns with definable aggregations
Currently it is possible to do multimatch captures, that is

```python
result = match(list_of_objects, Each({'item': _ >> 'values'}), multimatch=True)
```

will make `result.values` be a list of captures values. Whether a variable multimatches/aggregates or not and what method it uses (list, set, histogram) should be definable by the pattern, something like

```python
Each({'item': Capture('values', set)})
```


Discussion / hints:

Fix patch:
diff --git a/apm/__init__.py b/apm/__init__.py
index 5a24213..8a97dec 100644
--- a/apm/__init__.py
+++ b/apm/__init__.py
@@ -1,3 +1,4 @@
+from . import agg
 from .__pkginfo__ import __version__
 from .case_of import case
 from .core import \
@@ -51,6 +52,8 @@
 Object = Items
 
 __all__ = [
+    'agg',
+
     'AllOf',
     'Capture',
     'Either',
diff --git a/apm/agg.py b/apm/agg.py
new file mode 100644
index 0000000..4ef6811
--- /dev/null
+++ b/apm/agg.py
@@ -0,0 +1,51 @@
+from typing import Union
+
+from apm.core import Aggregation
+
+
+class Histogram(Aggregation[dict]):
+    def new(self) -> dict:
+        return dict()
+
+    def _add(self, aggregate: dict, value) -> dict:
+        if value not in aggregate:
+            aggregate[value] = 0
+        aggregate[value] += 1
+        return aggregate
+
+
+class Set(Aggregation[set]):
+    def new(self) -> set:
+        return set()
+
+    def _add(self, aggregate: set, value) -> set:
+        aggregate.add(value)
+        return aggregate
+
+
+class List(Aggregation[list]):
+    def new(self) -> list:
+        return list()
+
+    def _add(self, aggregate: list, value) -> list:
+        aggregate.append(value)
+        return aggregate
+
+
+class Sum(Aggregation[Union[int, float]]):
+    def new(self) -> Union[int, float]:
+        return 0
+
+    def _add(self, aggregate: Union[int, float], value) -> Union[int, float]:
+        try:
+            return aggregate + int(value)
+        except ValueError:
+            return aggregate + float(value)
+
+
+class Count(Aggregation[int]):
+    def new(self) -> int:
+        return 0
+
+    def _add(self, aggregate: int, value) -> int:
+        return aggregate + 1
diff --git a/apm/core.py b/apm/core.py
index 0771c97..cb1ce52 100644
--- a/apm/core.py
+++ b/apm/core.py
@@ -1,12 +1,14 @@
 from __future__ import annotations
 
 import collections.abc as abc
+from abc import abstractmethod, ABC
 from copy import copy
 from dataclasses import is_dataclass
 from itertools import chain
-from typing import Optional, List, Dict, Union, Tuple, Callable
+from typing import Optional, List, Dict, Union, Tuple, Callable, Generic, TypeVar
 
 from .generic import AutoEqHash, AutoRepr
+from .no_value import NoValue
 
 
 class WildcardMatch:
@@ -194,11 +196,16 @@ def explain(self, *, short: bool = False) -> str:
 
 
 class Capturable:
-    def __rshift__(self, other):
+    def __capture(self, other: Union[str, Aggregation]):
+        if isinstance(other, Aggregation):
+            return Capture(self, name=other.name, agg=other)
         return Capture(self, name=other)
 
-    def __rmatmul__(self, other):
-        return Capture(self, name=other)
+    def __rshift__(self, other: Union[str, Aggregation]):
+        return self.__capture(other)
+
+    def __rmatmul__(self, other: Union[str, Aggregation]):
+        return self.__capture(other)
 
 
 class Nested:
@@ -301,11 +308,43 @@ def descend(self, f):
         return String(*(f(p) for p in self._patterns))
 
 
+T = TypeVar('T')
+
+
+class Aggregation(Generic[T], ABC):
+    def __init__(self, name: Optional[str] = None):
+        self._name = name
+        self._value = NoValue
+
+    @property
+    def name(self) -> Optional[str]:
+        return self._name
+
+    @property
+    def value(self):
+        if self._value is NoValue:
+            return self.new()
+        return self._value
+
+    @abstractmethod
+    def new(self) -> T:
+        raise NotImplementedError
+
+    @abstractmethod
+    def _add(self, aggregate: T, value) -> T:
+        raise NotImplementedError
+
+    def add(self, aggregate: T, value) -> T:
+        self._value = self._add(aggregate, value)
+        return self._value
+
+
 class Capture(Pattern, Nested):
-    def __init__(self, pattern, *, name, target=None):
+    def __init__(self, pattern, *, name: str, target=None, agg: Optional[Aggregation] = None):
         self._pattern = pattern
-        self._name = name
+        self._name: str = name
         self._target = target
+        self._aggregation: Aggregation = agg
 
     def match(self, value, *, ctx: MatchContext, strict: bool) -> MatchResult:
         result = ctx.match(value, self._pattern, strict=strict)
@@ -316,7 +355,12 @@ def match(self, value, *, ctx: MatchContext, strict: bool) -> MatchResult:
 
     def capture(self, value, *, ctx: MatchContext):
         target = ctx if self._target is None else self._target
-        target[self._name] = value
+        if self._aggregation:
+            if self._name not in target:
+                target[self._name] = self._aggregation.new()
+            target[self._name] = self._aggregation.add(target[self._name], value)
+        else:
+            target[self._name] = value
 
     def get_capture_pattern_chain(self) -> Tuple[List[Capture], Pattern]:
         patterns = [self]


Test patch:
diff --git a/tests/test_agg.py b/tests/test_agg.py
new file mode 100644
index 0000000..912be87
--- /dev/null
+++ b/tests/test_agg.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+import unittest
+from typing import Callable
+
+from apm import *
+
+
+class AggregationsTest(unittest.TestCase):
+
+    def test_agg_set(self):
+        result = match([
+            'foo',
+            3,
+            'bar',
+            10,
+            -4,
+            'foo',
+            'bar',
+            17,
+        ], Each(OneOf(InstanceOf(str) >> agg.Set('strings'), ...)))
+        self.assertTrue(result)
+        self.assertEqual(result.strings, {'foo', 'bar'})
+
+    def test_agg_list(self):
+        result = match([
+            'foo',
+            3,
+            'bar',
+            10,
+            -4,
+            'foo',
+            'bar',
+            17,
+        ], Each(OneOf(InstanceOf(str) >> agg.List('strings'), ...)))
+        self.assertTrue(result)
+        self.assertEqual(result.strings, ['foo', 'bar', 'foo', 'bar'])
+
+    def test_agg_count(self):
+        result = match([
+            1,
+            2,
+            3.0,
+            4,
+            5.0,
+        ], Each(OneOf(
+            InstanceOf(int) >> agg.Count('integers'),
+            InstanceOf(float) >> agg.Count('floats'),
+        )))
+        self.assertTrue(result)
+        self.assertEqual(3, result.integers)
+        self.assertEqual(2, result.floats)
+
+    def test_agg_histogram(self):
+        result = match([
+            'a', 'b', 'c', 'b', 'd', 'c', 'a', 'a', 'c', 'a'
+        ], Each(_ >> agg.Histogram('histo')))
+        self.assertTrue(result)
+        self.assertEqual({
+            'a': 4,
+            'b': 2,
+            'c': 3,
+            'd': 1,
+        }, result.histo)
+
+    def test_agg_sum(self):
+        lower = 1
+        upper = 10
+        result = match(range(lower, upper), Each(_ >> agg.Sum('sum')))
+        length = upper - lower
+        self.assertTrue(result)
+        self.assertEqual(length * (length + 1) // 2, result.sum)
+
+    def test_agg_float_sum(self):
+        result = match(['1', '10.3'], Each(_ >> agg.Sum('sum')))
+        self.assertTrue(result)
+        self.assertEqual(11.3, result.sum)
+
+    def test_some_more_complicated_pattern(self):
+        class Recursive(Pattern):
+            def __init__(self, p: Callable):
+                self._pattern = p
+
+            def match(self, value, *, ctx: MatchContext, strict: bool) -> MatchResult:
+                return ctx.match(value, self._pattern())
+
+        numbers = agg.Set()
+        pattern = OneOf(InstanceOf(int) >> numbers, EachItem(..., Recursive(lambda: pattern)))
+
+        result = match({
+            'a': {'b': 3},
+            'c': 7,
+            'd': {'e': {}},
+            'f': {'g': {'h': 10, 'i': 7}},
+        }, pattern)
+        self.assertTrue(result)
+        self.assertEqual({3, 7, 10}, numbers.value)
+
+    def test_some_more_complicated_pattern_with_nothing_in_it(self):
+        class Recursive(Pattern):
+            def __init__(self, p: Callable):
+                self._pattern = p
+
+            def match(self, value, *, ctx: MatchContext, strict: bool) -> MatchResult:
+                return ctx.match(value, self._pattern())
+
+        numbers = agg.Set()
+        pattern = OneOf(InstanceOf(float) >> numbers, InstanceOf(int), EachItem(..., Recursive(lambda: pattern)))
+
+        result = match({
+            'a': {'b': 3},
+            'c': 7,
+            'd': {'e': {}},
+            'f': {'g': {'h': 10, 'i': 7}},
+        }, pattern)
+        self.assertTrue(result)
+        self.assertEqual(set(), numbers.value)
diff --git a/tests/test_issues.py b/tests/test_issues.py
index 1238cd4..ac51c33 100644
--- a/tests/test_issues.py
+++ b/tests/test_issues.py
@@ -19,3 +19,7 @@ def test_issue_2(self):
 
         result = match(Foo(1), Foo, 'this matches', 'no match unfortunately')
         self.assertEqual('this matches', result)
+
+        self.assertTrue(match(Foo(1), Foo, True, False))
+        self.assertTrue(match(Foo(1), Foo(1), True, False))
+        self.assertFalse(match(Foo(1), Foo(2), True, False))