pyaggregation 0.1.0__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.
@@ -0,0 +1,273 @@
1
+ """Compile a report from JSON: `{"aggregates": [{"name", "fold", "of", "where"}], "group_by"}`.
2
+
3
+ pyaggregation does not know the expression languages inside `of`, `where` and
4
+ `group_by`. They are delegated to injected compilers, e.g. pyformula's
5
+ `FormulaCompiler` for values and pyspecification's `PredicateCompiler` for conditions.
6
+ """
7
+
8
+ from collections.abc import Callable, Hashable, Mapping
9
+ from typing import Any, NamedTuple, NotRequired, Protocol, TypedDict, cast
10
+
11
+ from pyvocabulary import index_path, key_path, located, validate_name
12
+
13
+ from .aggregates import (
14
+ average,
15
+ collect,
16
+ count,
17
+ count_distinct,
18
+ every,
19
+ first,
20
+ join,
21
+ last,
22
+ maximum,
23
+ median,
24
+ minimum,
25
+ mode,
26
+ percentile,
27
+ product,
28
+ share,
29
+ some,
30
+ stdev,
31
+ total,
32
+ variance,
33
+ )
34
+ from .exceptions import (
35
+ DuplicateResultNameError,
36
+ InvalidAggregateError,
37
+ InvalidReportDefinitionError,
38
+ InvalidResultNameError,
39
+ )
40
+ from .report import Condition, GroupedReport, Report, Value
41
+
42
+ type Build = Callable[[str, Any, Any, dict[str, Any]], Report[Any]]
43
+
44
+
45
+ class FoldSpec(NamedTuple):
46
+ """The JSON fields of one fold, besides `name` and `fold`, and how to build it."""
47
+
48
+ required: frozenset[str]
49
+ optional: frozenset[str]
50
+ build: Build
51
+
52
+
53
+ def _of_first(build: Callable[..., Report[Any]]) -> Build:
54
+ return lambda name, of, where, options: build(of, name=name, where=where, **options)
55
+
56
+
57
+ _OF = frozenset({"of"})
58
+ _WHERE = frozenset({"where"})
59
+
60
+
61
+ def _simple(build: Callable[..., Report[Any]]) -> FoldSpec:
62
+ return FoldSpec(_OF, _WHERE, _of_first(build))
63
+
64
+
65
+ def _count(name: str, _of: Any, where: Any, _options: dict[str, Any]) -> Report[Any]:
66
+ return count(name=name, where=where)
67
+
68
+
69
+ def _share(name: str, of: Any, where: Any, _options: dict[str, Any]) -> Report[Any]:
70
+ return share(name=name, where=where, of=of)
71
+
72
+
73
+ FOLDS: Mapping[str, FoldSpec] = {
74
+ "average": _simple(average),
75
+ "collect": _simple(collect),
76
+ "count": FoldSpec(frozenset(), _WHERE, _count),
77
+ "count_distinct": _simple(count_distinct),
78
+ "every": _simple(every),
79
+ "first": _simple(first),
80
+ "join": FoldSpec(_OF, _WHERE | {"separator"}, _of_first(join)),
81
+ "last": _simple(last),
82
+ "maximum": _simple(maximum),
83
+ "median": _simple(median),
84
+ "minimum": _simple(minimum),
85
+ "mode": _simple(mode),
86
+ "percentile": FoldSpec(_OF | {"at"}, _WHERE, _of_first(percentile)),
87
+ "product": _simple(product),
88
+ "share": FoldSpec(_WHERE, _OF, _share),
89
+ "some": _simple(some),
90
+ "stdev": FoldSpec(_OF, _WHERE | {"sample"}, _of_first(stdev)),
91
+ "total": _simple(total),
92
+ "variance": FoldSpec(_OF, _WHERE | {"sample"}, _of_first(variance)),
93
+ }
94
+
95
+ # Plain JSON values passed to a built-in as they are; `of` and `where` are compiled.
96
+ _OPTIONS = frozenset({"at", "sample", "separator"})
97
+
98
+ _REPORT_KEYS = frozenset({"aggregates", "group_by"})
99
+
100
+
101
+ class AggregateDict(TypedDict):
102
+ name: str
103
+ fold: str
104
+ of: NotRequired[Any]
105
+ where: NotRequired[Any]
106
+
107
+
108
+ class ReportDict(TypedDict):
109
+ aggregates: list[AggregateDict]
110
+ group_by: NotRequired[Any]
111
+
112
+
113
+ class Compiler[F](Protocol):
114
+ """Anything with a `compile(definition) -> F` method."""
115
+
116
+ def compile(self, definition: Any, /) -> F: ...
117
+
118
+
119
+ class ReportCompiler[T]:
120
+ """Compile report definitions, delegating `of`, `where` and `group_by` to injected compilers.
121
+
122
+ Every problem is reported here, before any item is folded. Errors from the
123
+ injected compilers keep their type and get a note with the full JSON path.
124
+
125
+ Args:
126
+ values: Compiles each `of`.
127
+ conditions: Compiles each `where`.
128
+ keys: Compiles `group_by`. Without it, grouped definitions are rejected.
129
+ start: The `start` of every `total`, e.g. `Decimal(0)` in a money app,
130
+ since JSON has no `Decimal`.
131
+
132
+ Example:
133
+ ```python
134
+ >>> class Field:
135
+ ... def compile(self, name):
136
+ ... return lambda item: item[name]
137
+ >>> compiler = ReportCompiler(Field(), Field(), Field())
138
+ >>> report = compiler.compile({
139
+ ... "aggregates": [
140
+ ... {"name": "stock", "fold": "total", "of": "quantity"},
141
+ ... {"name": "on_sale", "fold": "count", "where": "sale"},
142
+ ... ],
143
+ ... "group_by": "shop",
144
+ ... })
145
+ >>> report
146
+ Report(stock, on_sale).group_by(<lambda>)
147
+ >>> report([
148
+ ... {"shop": "north", "quantity": 3, "sale": True},
149
+ ... {"shop": "north", "quantity": 4, "sale": False},
150
+ ... ])
151
+ {'north': {'stock': 7, 'on_sale': 1}}
152
+
153
+ ```
154
+
155
+ """
156
+
157
+ __slots__ = ("_conditions", "_keys", "_start", "_values")
158
+
159
+ def __init__(
160
+ self,
161
+ values: Compiler[Value[T]],
162
+ conditions: Compiler[Condition[T]],
163
+ keys: Compiler[Callable[[T], Hashable]] | None = None,
164
+ /,
165
+ *,
166
+ start: Any = 0,
167
+ ) -> None:
168
+ self._values = values
169
+ self._conditions = conditions
170
+ self._keys = keys
171
+ self._start = start
172
+
173
+ def compile(
174
+ self, definition: ReportDict | Mapping[str, Any], /
175
+ ) -> Report[T] | GroupedReport[T, Hashable]:
176
+ """Compile `definition` into a `Report`, or a `GroupedReport` with `group_by`.
177
+
178
+ Raises:
179
+ InvalidReportDefinitionError: If the definition has the wrong shape.
180
+
181
+ """
182
+ _check_keys(definition, required={"aggregates"}, allowed=_REPORT_KEYS, path="$")
183
+
184
+ path = key_path("$", "aggregates")
185
+ items = definition["aggregates"]
186
+
187
+ if not isinstance(items, list) or not items:
188
+ msg = f"Invalid report at {path}: expected a non-empty list, received {items!r}"
189
+ raise InvalidReportDefinitionError(msg)
190
+
191
+ compiled = [
192
+ self._compile_aggregate(item, index_path(path, index))
193
+ for index, item in enumerate(cast("list[Any]", items))
194
+ ]
195
+
196
+ with located(path, DuplicateResultNameError):
197
+ report = Report(tuple(aggregate for each in compiled for aggregate in each.aggregates))
198
+
199
+ if "group_by" not in definition:
200
+ return report
201
+
202
+ path = key_path("$", "group_by")
203
+
204
+ if self._keys is None:
205
+ msg = f"Invalid report at {path}: grouping needs a key compiler, and none was given"
206
+ raise InvalidReportDefinitionError(msg)
207
+
208
+ with located(path, Exception):
209
+ key = self._keys.compile(definition["group_by"])
210
+
211
+ return report.group_by(key)
212
+
213
+ def _compile_aggregate(self, item: Any, path: str) -> Report[T]:
214
+ _check_keys(item, required=set(), allowed=None, path=path)
215
+
216
+ fold = item.get("fold")
217
+
218
+ if fold not in FOLDS:
219
+ msg = (
220
+ f"Invalid report at {key_path(path, 'fold')}: unknown fold {fold!r}. "
221
+ f"Available folds: {', '.join(FOLDS)}"
222
+ )
223
+ raise InvalidReportDefinitionError(msg)
224
+
225
+ required, optional, build = FOLDS[fold]
226
+ _check_keys(
227
+ item,
228
+ required={"name", "fold", *required},
229
+ allowed={"name", "fold", *required, *optional},
230
+ path=path,
231
+ )
232
+
233
+ with located(key_path(path, "name"), InvalidResultNameError):
234
+ name = validate_name(item["name"], error=InvalidResultNameError)
235
+
236
+ of = where = None
237
+
238
+ if "of" in item:
239
+ with located(key_path(path, "of"), Exception):
240
+ of = self._values.compile(item["of"])
241
+
242
+ if "where" in item:
243
+ with located(key_path(path, "where"), Exception):
244
+ where = self._conditions.compile(item["where"])
245
+
246
+ options = {key: item[key] for key in _OPTIONS & item.keys()}
247
+
248
+ if fold == "total":
249
+ options["start"] = self._start
250
+
251
+ with located(path, InvalidAggregateError):
252
+ return build(name, of, where, options)
253
+
254
+
255
+ def _check_keys(
256
+ item: Any, *, required: set[str], allowed: set[str] | frozenset[str] | None, path: str
257
+ ) -> None:
258
+ if not isinstance(item, Mapping):
259
+ msg = f"Invalid report at {path}: expected an object, received {item!r}"
260
+ raise InvalidReportDefinitionError(msg)
261
+
262
+ keys = set(cast("Mapping[str, Any]", item))
263
+
264
+ if missing := sorted(required - keys):
265
+ msg = f"Invalid report at {path}: missing {', '.join(map(repr, missing))}"
266
+ raise InvalidReportDefinitionError(msg)
267
+
268
+ if allowed is not None and (unknown := sorted(keys - allowed)):
269
+ msg = (
270
+ f"Invalid report at {path}: unknown {', '.join(map(repr, unknown))}. "
271
+ f"Allowed keys: {', '.join(sorted(allowed))}"
272
+ )
273
+ raise InvalidReportDefinitionError(msg)
@@ -0,0 +1,51 @@
1
+ """Exceptions raised by pyaggregation.
2
+
3
+ Errors raised while a report runs (by `of`, `where`, a key or a fold) keep their
4
+ own type; pyaggregation only adds a note naming the aggregate, e.g.
5
+ `in aggregate 'payroll'`.
6
+ """
7
+
8
+ from collections.abc import Iterable
9
+
10
+ from pyvocabulary import InvalidNameError
11
+
12
+
13
+ class PyaggregationError(Exception):
14
+ """Base class for every exception raised by pyaggregation."""
15
+
16
+
17
+ class InvalidResultNameError(PyaggregationError, InvalidNameError):
18
+ """Raised when a result name is not a valid ASCII Python identifier."""
19
+
20
+ def __init__(self, name: object, /) -> None:
21
+ super().__init__(name, kind="result")
22
+
23
+
24
+ class DuplicateResultNameError(PyaggregationError, ValueError):
25
+ """Raised when two aggregates of one report share a result name.
26
+
27
+ Example:
28
+ ```python
29
+ >>> str(DuplicateResultNameError("payroll", ["payroll", "headcount"]))
30
+ "Result name 'payroll' is used more than once. Result names: payroll, headcount"
31
+
32
+ ```
33
+
34
+ """
35
+
36
+ def __init__(self, name: str, names: Iterable[str], /) -> None:
37
+ super().__init__(
38
+ f"Result name '{name}' is used more than once. Result names: {', '.join(names)}"
39
+ )
40
+
41
+
42
+ class InvalidAggregateError(PyaggregationError, TypeError):
43
+ """Raised when an aggregate or report is built from parts that cannot work."""
44
+
45
+
46
+ class InvalidGroupKeyError(PyaggregationError, TypeError):
47
+ """Raised when `group_by` is given a key that is not callable."""
48
+
49
+
50
+ class InvalidReportDefinitionError(PyaggregationError, ValueError):
51
+ """Raised when a JSON report definition does not have the expected shape."""
@@ -0,0 +1,99 @@
1
+ """The JSON Schema of a report definition, as accepted by `ReportCompiler.compile`.
2
+
3
+ pyaggregation does not know the languages inside `of`, `where` and `group_by`,
4
+ so their schemas are passed in, e.g. pyspecification's `get_expression_json_schema(rules)`.
5
+ """
6
+
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ from pyvocabulary import JSON_SCHEMA_DIALECT
11
+
12
+ from .compilers import FOLDS
13
+
14
+ VALUE_SCHEMA_ID = "urn:pyaggregation:value"
15
+ CONDITION_SCHEMA_ID = "urn:pyaggregation:condition"
16
+ KEY_SCHEMA_ID = "urn:pyaggregation:key"
17
+
18
+ _NAME_SCHEMA = {"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"}
19
+ _FIELD_SCHEMAS = {
20
+ "of": {"$ref": VALUE_SCHEMA_ID},
21
+ "where": {"$ref": CONDITION_SCHEMA_ID},
22
+ "at": {"type": "number", "minimum": 0, "maximum": 1},
23
+ "sample": {"type": "boolean"},
24
+ "separator": {"type": "string"},
25
+ }
26
+
27
+
28
+ def get_report_json_schema(
29
+ value_schema: Mapping[str, Any],
30
+ condition_schema: Mapping[str, Any],
31
+ key_schema: Mapping[str, Any] | None = None,
32
+ /,
33
+ ) -> dict[str, Any]:
34
+ """Return the JSON Schema of `{"aggregates": [{"name", "fold", "of", "where"}], "group_by"}`.
35
+
36
+ Each fold has its own variant, so `of` and `where` are required or forbidden
37
+ exactly as the compiler requires or forbids them. Each sub-schema is embedded
38
+ under its own `$id`, so the `#/$defs/...` refs it uses keep pointing into itself.
39
+
40
+ Args:
41
+ value_schema: The schema of one `of`, e.g. a pyformula expression.
42
+ condition_schema: The schema of one `where`, e.g. a pyspecification expression.
43
+ key_schema: The schema of `group_by`. Without it, `group_by` is not allowed,
44
+ matching a `ReportCompiler` without a key compiler.
45
+
46
+ Example:
47
+ ```python
48
+ >>> schema = get_report_json_schema({"type": "string"}, {"type": "string"})
49
+ >>> list(schema["properties"]), schema["$defs"]["count"]["required"]
50
+ (['aggregates'], ['name', 'fold'])
51
+ >>> schema["$defs"]["share"]["required"]
52
+ ['name', 'fold', 'where']
53
+
54
+ ```
55
+
56
+ """
57
+ defs: dict[str, Any] = {
58
+ fold: _aggregate_schema(fold, spec.required, spec.optional) for fold, spec in FOLDS.items()
59
+ }
60
+ defs["value"] = {**value_schema, "$id": VALUE_SCHEMA_ID}
61
+ defs["condition"] = {**condition_schema, "$id": CONDITION_SCHEMA_ID}
62
+
63
+ properties: dict[str, Any] = {
64
+ "aggregates": {
65
+ "type": "array",
66
+ "minItems": 1,
67
+ "items": {"oneOf": [{"$ref": f"#/$defs/{fold}"} for fold in FOLDS]},
68
+ },
69
+ }
70
+
71
+ if key_schema is not None:
72
+ properties["group_by"] = {"$ref": KEY_SCHEMA_ID}
73
+ defs["key"] = {**key_schema, "$id": KEY_SCHEMA_ID}
74
+
75
+ return {
76
+ "$schema": JSON_SCHEMA_DIALECT,
77
+ "type": "object",
78
+ "properties": properties,
79
+ "required": ["aggregates"],
80
+ "additionalProperties": False,
81
+ "$defs": defs,
82
+ }
83
+
84
+
85
+ def _aggregate_schema(
86
+ fold: str, required: frozenset[str], optional: frozenset[str]
87
+ ) -> dict[str, Any]:
88
+ fields = sorted(required | optional)
89
+
90
+ return {
91
+ "type": "object",
92
+ "properties": {
93
+ "name": _NAME_SCHEMA,
94
+ "fold": {"const": fold},
95
+ **{field: _FIELD_SCHEMAS[field] for field in fields},
96
+ },
97
+ "required": ["name", "fold", *sorted(required)],
98
+ "additionalProperties": False,
99
+ }
pyaggregation/py.typed ADDED
File without changes
@@ -0,0 +1,232 @@
1
+ """Reports: several folds run together over one iterable, in one pass.
2
+
3
+ ```python
4
+ report = total(salary, name="payroll") & count(name="managers", where=is_manager)
5
+ report(employees) # {"payroll": ..., "managers": 2}
6
+ report.group_by(department)(employees) # {"Sales": {...}, "IT": {...}}
7
+ ```
8
+
9
+ The input is iterated exactly once, so generators and database cursors work and
10
+ only the accumulators are held in memory.
11
+ """
12
+
13
+ from collections.abc import Callable, Hashable, Iterable
14
+ from dataclasses import dataclass
15
+ from functools import partial
16
+ from typing import Any, cast
17
+
18
+ from pyvocabulary import validate_name
19
+
20
+ from .exceptions import (
21
+ DuplicateResultNameError,
22
+ InvalidAggregateError,
23
+ InvalidGroupKeyError,
24
+ InvalidResultNameError,
25
+ )
26
+
27
+ type Value[T] = Callable[[T], Any]
28
+ type Condition[T] = Callable[[T], Any]
29
+
30
+
31
+ def identity[V](value: V, /) -> V:
32
+ """Return `value` unchanged: the default `of` of an aggregate."""
33
+ return value
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Fold[V, A, R]:
38
+ """Three pure functions that reduce values to one result.
39
+
40
+ `initial` is a function, not a value, so every run starts from a fresh
41
+ accumulator, even a mutable one.
42
+
43
+ Example:
44
+ ```python
45
+ >>> longest = Fold(lambda: "", lambda acc, word: max(acc, word, key=len), str.upper)
46
+ >>> aggregate(longest, name="longest")(["fold", "report", "sum"])
47
+ {'longest': 'REPORT'}
48
+
49
+ ```
50
+
51
+ """
52
+
53
+ initial: Callable[[], A]
54
+ step: Callable[[A, V], A]
55
+ finish: Callable[[A], R]
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class Aggregate[T]:
60
+ """One named result: `fold` over `of(item)` for every item that passes `where`."""
61
+
62
+ name: str
63
+ fold: Fold[Any, Any, Any]
64
+ of: Value[T] = identity
65
+ where: Condition[T] | None = None
66
+
67
+ def __post_init__(self) -> None:
68
+ validate_name(self.name, error=InvalidResultNameError)
69
+
70
+ # The annotation is a promise to type checkers; callers may still pass anything.
71
+ if not isinstance(cast("object", self.fold), Fold):
72
+ msg = f"Aggregate '{self.name}' must have a Fold, received {self.fold!r}"
73
+ raise InvalidAggregateError(msg)
74
+
75
+ check_callable(self.of, role="of", name=self.name)
76
+
77
+ if self.where is not None:
78
+ check_callable(self.where, role="where", name=self.name)
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class Report[T]:
83
+ """Aggregates in result order. Calling it folds an iterable into one dict.
84
+
85
+ Build reports with the built-ins (`total`, `count`, ...) or `aggregate`, then
86
+ join them with `&`. A repeated result name is rejected when the report is built.
87
+
88
+ Example:
89
+ ```python
90
+ >>> from pyaggregation import count, total
91
+ >>> report = total(len, name="letters") & count(name="words", where=str.istitle)
92
+ >>> report
93
+ Report(letters, words)
94
+ >>> report(iter(["Fold", "every", "Word"]))
95
+ {'letters': 13, 'words': 2}
96
+ >>> report.group_by(len)(["Fold", "every", "Word"])
97
+ {4: {'letters': 8, 'words': 2}, 5: {'letters': 5, 'words': 0}}
98
+
99
+ ```
100
+
101
+ """
102
+
103
+ aggregates: tuple[Aggregate[T], ...]
104
+
105
+ def __post_init__(self) -> None:
106
+ if not self.aggregates:
107
+ msg = "A report needs at least one aggregate, received none"
108
+ raise InvalidAggregateError(msg)
109
+
110
+ names = [aggregate.name for aggregate in self.aggregates]
111
+
112
+ if duplicate := next((name for name in names if names.count(name) > 1), None):
113
+ raise DuplicateResultNameError(duplicate, dict.fromkeys(names))
114
+
115
+ def __call__(self, items: Iterable[T], /) -> dict[str, Any]:
116
+ accumulators = _start(self.aggregates)
117
+
118
+ for item in items:
119
+ _step(self.aggregates, accumulators, item)
120
+
121
+ return _finish(self.aggregates, accumulators)
122
+
123
+ def __and__(self, other: "Report[T]", /) -> "Report[T]":
124
+ match other:
125
+ case Report(aggregates=aggregates):
126
+ return Report((*self.aggregates, *aggregates))
127
+ case _:
128
+ return NotImplemented
129
+
130
+ def group_by[K: Hashable](self, key: Callable[[T], K], /) -> "GroupedReport[T, K]":
131
+ """Return a report that folds each group of items with the same `key` separately."""
132
+ return GroupedReport(self, key)
133
+
134
+ def __repr__(self) -> str:
135
+ return f"Report({', '.join(aggregate.name for aggregate in self.aggregates)})"
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class GroupedReport[T, K: Hashable]:
140
+ """A report per key, still in one pass. Groups come in first-seen order."""
141
+
142
+ report: Report[T]
143
+ key: Callable[[T], K]
144
+
145
+ def __post_init__(self) -> None:
146
+ if not callable(self.key):
147
+ msg = f"The group_by key must be callable, received {self.key!r}"
148
+ raise InvalidGroupKeyError(msg)
149
+
150
+ def __call__(self, items: Iterable[T], /) -> dict[K, dict[str, Any]]:
151
+ aggregates = self.report.aggregates
152
+ groups: dict[K, list[Any]] = {}
153
+
154
+ for item in items:
155
+ key = self.key(item)
156
+
157
+ if (accumulators := groups.get(key)) is None:
158
+ accumulators = groups[key] = _start(aggregates)
159
+
160
+ _step(aggregates, accumulators, item)
161
+
162
+ return {key: _finish(aggregates, accumulators) for key, accumulators in groups.items()}
163
+
164
+ def __repr__(self) -> str:
165
+ return f"{self.report!r}.group_by({getattr(self.key, '__name__', self.key)})"
166
+
167
+
168
+ def aggregate[T](
169
+ fold: Fold[Any, Any, Any],
170
+ /,
171
+ *,
172
+ name: str,
173
+ of: Value[T] | None = None,
174
+ where: Condition[T] | None = None,
175
+ ) -> Report[T]:
176
+ """Return a one-result report from any fold: the way to add folds beyond the built-ins.
177
+
178
+ Args:
179
+ fold: Reduces the values to the result.
180
+ name: The result's key in the report's dict.
181
+ of: Computes the value folded for each item. Defaults to the item itself.
182
+ where: Keeps only the items it is true for. Runs before `of`.
183
+
184
+ Example:
185
+ ```python
186
+ >>> distinct = Fold(frozenset, lambda seen, value: seen | {value}, len)
187
+ >>> aggregate(distinct, name="letters", of=str.lower)("AbBa")
188
+ {'letters': 2}
189
+
190
+ ```
191
+
192
+ """
193
+ return Report((Aggregate(name, fold, identity if of is None else of, where),))
194
+
195
+
196
+ def check_callable(value: object, /, *, role: str, name: str) -> None:
197
+ """Raise `InvalidAggregateError` unless `value` is callable."""
198
+ if not callable(value):
199
+ msg = f"The {role} of aggregate '{name}' must be callable, received {value!r}"
200
+ raise InvalidAggregateError(msg)
201
+
202
+
203
+ # --- one pass
204
+
205
+
206
+ def _start[T](aggregates: tuple[Aggregate[T], ...]) -> list[Any]:
207
+ return [_named(aggregate, aggregate.fold.initial) for aggregate in aggregates]
208
+
209
+
210
+ def _step[T](aggregates: tuple[Aggregate[T], ...], accumulators: list[Any], item: T) -> None:
211
+ for index, aggregate in enumerate(aggregates):
212
+ try:
213
+ if aggregate.where is None or aggregate.where(item):
214
+ accumulators[index] = aggregate.fold.step(accumulators[index], aggregate.of(item))
215
+ except Exception as error:
216
+ error.add_note(f"in aggregate '{aggregate.name}'")
217
+ raise
218
+
219
+
220
+ def _finish[T](aggregates: tuple[Aggregate[T], ...], accumulators: list[Any]) -> dict[str, Any]:
221
+ return {
222
+ aggregate.name: _named(aggregate, partial(aggregate.fold.finish, accumulator))
223
+ for aggregate, accumulator in zip(aggregates, accumulators, strict=True)
224
+ }
225
+
226
+
227
+ def _named[R](aggregate: Aggregate[Any], call: Callable[[], R]) -> R:
228
+ try:
229
+ return call()
230
+ except Exception as error:
231
+ error.add_note(f"in aggregate '{aggregate.name}'")
232
+ raise