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.
- pyaggregation/__init__.py +96 -0
- pyaggregation/aggregates.py +514 -0
- pyaggregation/compilers.py +273 -0
- pyaggregation/exceptions.py +51 -0
- pyaggregation/json_schema.py +99 -0
- pyaggregation/py.typed +0 -0
- pyaggregation/report.py +232 -0
- pyaggregation-0.1.0.dist-info/METADATA +663 -0
- pyaggregation-0.1.0.dist-info/RECORD +10 -0
- pyaggregation-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""pyaggregation: Summaries over collections, computed in one pass.
|
|
2
|
+
|
|
3
|
+
`total(salary, name="payroll") & count(name="managers", where=is_manager)`
|
|
4
|
+
is a `Report`: several folds run together over any iterable, groupable by a key.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .aggregates import (
|
|
8
|
+
average,
|
|
9
|
+
collect,
|
|
10
|
+
count,
|
|
11
|
+
count_distinct,
|
|
12
|
+
every,
|
|
13
|
+
first,
|
|
14
|
+
join,
|
|
15
|
+
last,
|
|
16
|
+
maximum,
|
|
17
|
+
median,
|
|
18
|
+
minimum,
|
|
19
|
+
mode,
|
|
20
|
+
percentile,
|
|
21
|
+
product,
|
|
22
|
+
share,
|
|
23
|
+
some,
|
|
24
|
+
stdev,
|
|
25
|
+
total,
|
|
26
|
+
variance,
|
|
27
|
+
)
|
|
28
|
+
from .compilers import AggregateDict, Compiler, ReportCompiler, ReportDict
|
|
29
|
+
from .exceptions import (
|
|
30
|
+
DuplicateResultNameError,
|
|
31
|
+
InvalidAggregateError,
|
|
32
|
+
InvalidGroupKeyError,
|
|
33
|
+
InvalidReportDefinitionError,
|
|
34
|
+
InvalidResultNameError,
|
|
35
|
+
PyaggregationError,
|
|
36
|
+
)
|
|
37
|
+
from .json_schema import (
|
|
38
|
+
CONDITION_SCHEMA_ID,
|
|
39
|
+
KEY_SCHEMA_ID,
|
|
40
|
+
VALUE_SCHEMA_ID,
|
|
41
|
+
get_report_json_schema,
|
|
42
|
+
)
|
|
43
|
+
from .report import (
|
|
44
|
+
Aggregate,
|
|
45
|
+
Condition,
|
|
46
|
+
Fold,
|
|
47
|
+
GroupedReport,
|
|
48
|
+
Report,
|
|
49
|
+
Value,
|
|
50
|
+
aggregate,
|
|
51
|
+
identity,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"CONDITION_SCHEMA_ID",
|
|
56
|
+
"KEY_SCHEMA_ID",
|
|
57
|
+
"VALUE_SCHEMA_ID",
|
|
58
|
+
"Aggregate",
|
|
59
|
+
"AggregateDict",
|
|
60
|
+
"Compiler",
|
|
61
|
+
"Condition",
|
|
62
|
+
"DuplicateResultNameError",
|
|
63
|
+
"Fold",
|
|
64
|
+
"GroupedReport",
|
|
65
|
+
"InvalidAggregateError",
|
|
66
|
+
"InvalidGroupKeyError",
|
|
67
|
+
"InvalidReportDefinitionError",
|
|
68
|
+
"InvalidResultNameError",
|
|
69
|
+
"PyaggregationError",
|
|
70
|
+
"Report",
|
|
71
|
+
"ReportCompiler",
|
|
72
|
+
"ReportDict",
|
|
73
|
+
"Value",
|
|
74
|
+
"aggregate",
|
|
75
|
+
"average",
|
|
76
|
+
"collect",
|
|
77
|
+
"count",
|
|
78
|
+
"count_distinct",
|
|
79
|
+
"every",
|
|
80
|
+
"first",
|
|
81
|
+
"get_report_json_schema",
|
|
82
|
+
"identity",
|
|
83
|
+
"join",
|
|
84
|
+
"last",
|
|
85
|
+
"maximum",
|
|
86
|
+
"median",
|
|
87
|
+
"minimum",
|
|
88
|
+
"mode",
|
|
89
|
+
"percentile",
|
|
90
|
+
"product",
|
|
91
|
+
"share",
|
|
92
|
+
"some",
|
|
93
|
+
"stdev",
|
|
94
|
+
"total",
|
|
95
|
+
"variance",
|
|
96
|
+
]
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""The built-in aggregates. Each returns a one-result `Report`, so they join with `&`.
|
|
2
|
+
|
|
3
|
+
- Sums: `total`, `product`, `count`, `count_distinct`.
|
|
4
|
+
- Averages and spread: `average`, `share`, `variance`, `stdev`.
|
|
5
|
+
- Order: `minimum`, `maximum`, `median`, `percentile`, `mode`, `first`, `last`.
|
|
6
|
+
- Truth: `some`, `every`.
|
|
7
|
+
- Collections: `collect`, `join`.
|
|
8
|
+
|
|
9
|
+
Values pass through unchanged, so `Decimal` stays `Decimal`; nothing is ever
|
|
10
|
+
rounded. Sums and collections of nothing are their empty value (`0`, `1`, `[]`,
|
|
11
|
+
`""`); every other aggregate of nothing is `None`, never an exception. `None`
|
|
12
|
+
values are not skipped: filter them out with `where`.
|
|
13
|
+
|
|
14
|
+
Most aggregates keep only a running value. `count_distinct` and `mode` keep one
|
|
15
|
+
entry per distinct value; `median`, `percentile`, `collect` and `join` keep
|
|
16
|
+
every value until the input ends.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
import operator
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from decimal import Decimal
|
|
24
|
+
from functools import partial
|
|
25
|
+
from typing import Any, cast
|
|
26
|
+
|
|
27
|
+
from .exceptions import InvalidAggregateError
|
|
28
|
+
from .report import Condition, Fold, Report, Value, aggregate, check_callable, identity
|
|
29
|
+
|
|
30
|
+
# Marks "no value yet", so a real `None` value is never mistaken for an empty input.
|
|
31
|
+
_MISSING: Any = object()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def total[T](
|
|
35
|
+
of: Value[T],
|
|
36
|
+
/,
|
|
37
|
+
*,
|
|
38
|
+
name: str,
|
|
39
|
+
where: Condition[T] | None = None,
|
|
40
|
+
start: Any = 0,
|
|
41
|
+
) -> Report[T]:
|
|
42
|
+
"""Sum the values. Pass `start=Decimal(0)` so an empty money total is a `Decimal`.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
```python
|
|
46
|
+
>>> from decimal import Decimal
|
|
47
|
+
>>> total(Decimal, name="paid", start=Decimal(0))(["1.10", "2.20"])
|
|
48
|
+
{'paid': Decimal('3.30')}
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
return aggregate(Fold(lambda: start, operator.add, identity), name=name, of=of, where=where)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def product[T](
|
|
57
|
+
of: Value[T],
|
|
58
|
+
/,
|
|
59
|
+
*,
|
|
60
|
+
name: str,
|
|
61
|
+
where: Condition[T] | None = None,
|
|
62
|
+
start: Any = 1,
|
|
63
|
+
) -> Report[T]:
|
|
64
|
+
"""Multiply the values, e.g. growth factors. No values give `start`.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
```python
|
|
68
|
+
>>> product(float, name="growth")([1.5, 2])
|
|
69
|
+
{'growth': 3.0}
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
return aggregate(Fold(lambda: start, operator.mul, identity), name=name, of=of, where=where)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def count[T](*, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
78
|
+
"""Count the items that pass `where` (all of them without it).
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
```python
|
|
82
|
+
>>> count(name="evens", where=lambda n: n % 2 == 0)(range(10))
|
|
83
|
+
{'evens': 5}
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
return aggregate(Fold(int, lambda n, _: n + 1, identity), name=name, where=where)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def count_distinct[T](
|
|
92
|
+
of: Value[T], /, *, name: str, where: Condition[T] | None = None
|
|
93
|
+
) -> Report[T]:
|
|
94
|
+
"""Count the different values. Values must be hashable; `1`, `1.0` and `True` are one value.
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
```python
|
|
98
|
+
>>> count_distinct(str.lower, name="letters")("AbBa")
|
|
99
|
+
{'letters': 2}
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
return aggregate(Fold(set, _add_to_set, len), name=name, of=of, where=where)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def average[T](of: Value[T], /, *, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
108
|
+
"""Average the values: `Decimal` stays `Decimal`, `int` gives `float`, no values give `None`.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
```python
|
|
112
|
+
>>> average(len, name="mean")(["ab", "abcd"]), average(len, name="mean")([])
|
|
113
|
+
({'mean': 3.0}, {'mean': None})
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
fold = Fold(lambda: (0, 0), _add_counted, _divide)
|
|
119
|
+
|
|
120
|
+
return aggregate(fold, name=name, of=of, where=where)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def share[T](*, name: str, where: Condition[T], of: Value[T] | None = None) -> Report[T]:
|
|
124
|
+
"""Return the part of the items (or of the total of `of`) for which `where` is true.
|
|
125
|
+
|
|
126
|
+
Unlike the other aggregates, `where` does not filter: it splits the items
|
|
127
|
+
into the part and the whole. An empty or zero whole gives `None`.
|
|
128
|
+
|
|
129
|
+
Example:
|
|
130
|
+
```python
|
|
131
|
+
>>> share(name="evens", where=lambda n: n % 2 == 0)([1, 2, 3, 4])
|
|
132
|
+
{'evens': 0.5}
|
|
133
|
+
>>> share(name="big", where=lambda n: n > 2, of=identity)([1, 2, 3, 4])
|
|
134
|
+
{'big': 0.7}
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
check_callable(where, role="where", name=name)
|
|
140
|
+
|
|
141
|
+
value: Value[T] = _one if of is None else of
|
|
142
|
+
|
|
143
|
+
def step(acc: tuple[Any, Any], item: T) -> tuple[Any, Any]:
|
|
144
|
+
part, whole = acc
|
|
145
|
+
amount = value(item)
|
|
146
|
+
|
|
147
|
+
return (part + amount if where(item) else part, whole + amount)
|
|
148
|
+
|
|
149
|
+
return aggregate(Fold(lambda: (0, 0), step, _divide), name=name)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def variance[T](
|
|
153
|
+
of: Value[T],
|
|
154
|
+
/,
|
|
155
|
+
*,
|
|
156
|
+
name: str,
|
|
157
|
+
where: Condition[T] | None = None,
|
|
158
|
+
sample: bool = True,
|
|
159
|
+
) -> Report[T]:
|
|
160
|
+
"""Return the variance, like `statistics.variance` (or `pvariance` with `sample=False`).
|
|
161
|
+
|
|
162
|
+
Computed in one pass with Welford's method, which stays accurate for values
|
|
163
|
+
with a large common offset. A sample needs two values, a population one;
|
|
164
|
+
fewer give `None`. `Decimal` stays `Decimal`.
|
|
165
|
+
|
|
166
|
+
Example:
|
|
167
|
+
```python
|
|
168
|
+
>>> variance(int, name="var")([1, 2, 3]), variance(int, name="var", sample=False)([1, 2, 3])
|
|
169
|
+
({'var': 1.0}, {'var': 0.666...})
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
_check_type(sample, bool, role="sample", name=name)
|
|
175
|
+
|
|
176
|
+
fold = Fold(lambda: (0, 0, 0), _welford, partial(_variance, sample=sample))
|
|
177
|
+
|
|
178
|
+
return aggregate(fold, name=name, of=of, where=where)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def stdev[T](
|
|
182
|
+
of: Value[T],
|
|
183
|
+
/,
|
|
184
|
+
*,
|
|
185
|
+
name: str,
|
|
186
|
+
where: Condition[T] | None = None,
|
|
187
|
+
sample: bool = True,
|
|
188
|
+
) -> Report[T]:
|
|
189
|
+
"""Return the standard deviation: the square root of `variance`, with the same rules.
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
```python
|
|
193
|
+
>>> stdev(int, name="sd", sample=False)([2, 4, 4, 4, 5, 5, 7, 9])
|
|
194
|
+
{'sd': 2.0}
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
"""
|
|
199
|
+
_check_type(sample, bool, role="sample", name=name)
|
|
200
|
+
|
|
201
|
+
fold = Fold(lambda: (0, 0, 0), _welford, partial(_stdev, sample=sample))
|
|
202
|
+
|
|
203
|
+
return aggregate(fold, name=name, of=of, where=where)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def minimum[T](of: Value[T], /, *, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
207
|
+
"""Return the smallest value, or `None` without values.
|
|
208
|
+
|
|
209
|
+
Example:
|
|
210
|
+
```python
|
|
211
|
+
>>> minimum(len, name="shortest")(["fold", "a", "sum"])
|
|
212
|
+
{'shortest': 1}
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
"""
|
|
217
|
+
return aggregate(Fold(_missing, _keep(min), _present), name=name, of=of, where=where)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def maximum[T](of: Value[T], /, *, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
221
|
+
"""Return the largest value, or `None` without values.
|
|
222
|
+
|
|
223
|
+
Example:
|
|
224
|
+
```python
|
|
225
|
+
>>> maximum(len, name="longest")(["fold", "a", "sum"])
|
|
226
|
+
{'longest': 4}
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
"""
|
|
231
|
+
return aggregate(Fold(_missing, _keep(max), _present), name=name, of=of, where=where)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def median[T](of: Value[T], /, *, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
235
|
+
"""Return the middle value, or the mean of the middle two. No values give `None`.
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
```python
|
|
239
|
+
>>> median(int, name="m")([3, 1, 2]), median(int, name="m")([4, 1, 3, 2])
|
|
240
|
+
({'m': 2}, {'m': 2.5})
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
"""
|
|
245
|
+
fold = Fold(list, _append, partial(_percentile, at=0.5))
|
|
246
|
+
|
|
247
|
+
return aggregate(fold, name=name, of=of, where=where)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def percentile[T](
|
|
251
|
+
of: Value[T],
|
|
252
|
+
/,
|
|
253
|
+
*,
|
|
254
|
+
name: str,
|
|
255
|
+
at: float | Decimal,
|
|
256
|
+
where: Condition[T] | None = None,
|
|
257
|
+
) -> Report[T]:
|
|
258
|
+
"""Return the value at fraction `at` (0 to 1) of the sorted values, interpolated linearly.
|
|
259
|
+
|
|
260
|
+
The same method as SQL's `PERCENTILE_CONT` and NumPy's default: `at=0` is the
|
|
261
|
+
minimum, `at=1` the maximum, `at=0.5` the median. `Decimal` values stay
|
|
262
|
+
`Decimal`, even with a float `at`. No values give `None`.
|
|
263
|
+
|
|
264
|
+
Example:
|
|
265
|
+
```python
|
|
266
|
+
>>> percentile(int, name="p90", at=0.9)(range(1, 11))
|
|
267
|
+
{'p90': 9.1}
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
"""
|
|
272
|
+
# The annotation is a promise to type checkers; JSON may still send anything.
|
|
273
|
+
value = cast("object", at)
|
|
274
|
+
|
|
275
|
+
if (
|
|
276
|
+
isinstance(value, bool)
|
|
277
|
+
or not isinstance(value, int | float | Decimal)
|
|
278
|
+
or not 0 <= value <= 1
|
|
279
|
+
):
|
|
280
|
+
msg = f"The at of aggregate '{name}' must be a number from 0 to 1, received {at!r}"
|
|
281
|
+
raise InvalidAggregateError(msg)
|
|
282
|
+
|
|
283
|
+
fold = Fold(list, _append, partial(_percentile, at=at))
|
|
284
|
+
|
|
285
|
+
return aggregate(fold, name=name, of=of, where=where)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def mode[T](of: Value[T], /, *, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
289
|
+
"""Return the most common value; a tie goes to the value seen first. No values give `None`.
|
|
290
|
+
|
|
291
|
+
Example:
|
|
292
|
+
```python
|
|
293
|
+
>>> mode(str.lower, name="letter")("abBA")
|
|
294
|
+
{'letter': 'a'}
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
"""
|
|
299
|
+
return aggregate(Fold(Counter, _add_to_counter, _most_common), name=name, of=of, where=where)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def first[T](of: Value[T], /, *, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
303
|
+
"""Return the first value in input order, or `None` without values.
|
|
304
|
+
|
|
305
|
+
Example:
|
|
306
|
+
```python
|
|
307
|
+
>>> first(str.upper, name="initial", where=str.isalpha)("1ab")
|
|
308
|
+
{'initial': 'A'}
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
"""
|
|
313
|
+
return aggregate(Fold(_missing, _keep_first, _present), name=name, of=of, where=where)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def last[T](of: Value[T], /, *, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
317
|
+
"""Return the last value in input order, or `None` without values.
|
|
318
|
+
|
|
319
|
+
Example:
|
|
320
|
+
```python
|
|
321
|
+
>>> last(str.upper, name="final", where=str.isalpha)("ab1")
|
|
322
|
+
{'final': 'B'}
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
return aggregate(Fold(_missing, _keep_last, _present), name=name, of=of, where=where)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def some[T](of: Value[T], /, *, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
331
|
+
"""Return whether any value is truthy, or `None` without values.
|
|
332
|
+
|
|
333
|
+
Example:
|
|
334
|
+
```python
|
|
335
|
+
>>> some(lambda n: n > 2, name="big")([1, 3]), some(bool, name="big")([])
|
|
336
|
+
({'big': True}, {'big': None})
|
|
337
|
+
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
"""
|
|
341
|
+
return aggregate(Fold(lambda: None, _any, identity), name=name, of=of, where=where)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def every[T](of: Value[T], /, *, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
345
|
+
"""Return whether all values are truthy, or `None` without values.
|
|
346
|
+
|
|
347
|
+
No values give `None`, not `True`, like SQL's `EVERY`: "nothing to check"
|
|
348
|
+
must never read as "everything passed".
|
|
349
|
+
|
|
350
|
+
Example:
|
|
351
|
+
```python
|
|
352
|
+
>>> every(lambda n: n > 0, name="positive")([1, 3]), every(bool, name="ok")([])
|
|
353
|
+
({'positive': True}, {'ok': None})
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
"""
|
|
358
|
+
return aggregate(Fold(lambda: None, _all, identity), name=name, of=of, where=where)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def collect[T](of: Value[T], /, *, name: str, where: Condition[T] | None = None) -> Report[T]:
|
|
362
|
+
"""Return the values as a list, in input order. No values give `[]`.
|
|
363
|
+
|
|
364
|
+
Example:
|
|
365
|
+
```python
|
|
366
|
+
>>> collect(str.upper, name="letters", where=str.isalpha)("a1b")
|
|
367
|
+
{'letters': ['A', 'B']}
|
|
368
|
+
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
"""
|
|
372
|
+
return aggregate(Fold(list, _append, identity), name=name, of=of, where=where)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def join[T](
|
|
376
|
+
of: Value[T],
|
|
377
|
+
/,
|
|
378
|
+
*,
|
|
379
|
+
name: str,
|
|
380
|
+
where: Condition[T] | None = None,
|
|
381
|
+
separator: str = ", ",
|
|
382
|
+
) -> Report[T]:
|
|
383
|
+
"""Join string values with `separator`, in input order. No values give `""`.
|
|
384
|
+
|
|
385
|
+
Example:
|
|
386
|
+
```python
|
|
387
|
+
>>> join(str.upper, name="codes", separator="-")(["ab", "cd"])
|
|
388
|
+
{'codes': 'AB-CD'}
|
|
389
|
+
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
"""
|
|
393
|
+
_check_type(separator, str, role="separator", name=name)
|
|
394
|
+
|
|
395
|
+
return aggregate(Fold(list, _append, separator.join), name=name, of=of, where=where)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# --- folds
|
|
399
|
+
# Accumulators that grow (lists, sets, counters) are mutated in place: `initial`
|
|
400
|
+
# makes a fresh one per run, and copying on every step would make a run quadratic.
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _add_counted(acc: tuple[Any, int], value: Any) -> tuple[Any, int]:
|
|
404
|
+
return (acc[0] + value, acc[1] + 1)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _one(_: object, /) -> int:
|
|
408
|
+
return 1
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _divide(acc: tuple[Any, Any]) -> Any:
|
|
412
|
+
part, whole = acc
|
|
413
|
+
|
|
414
|
+
return None if whole == 0 else part / whole
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _missing() -> Any:
|
|
418
|
+
return _MISSING
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _present(acc: Any) -> Any:
|
|
422
|
+
return None if acc is _MISSING else acc
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _keep(choose: Callable[[Any, Any], Any]) -> Callable[[Any, Any], Any]:
|
|
426
|
+
return lambda acc, value: value if acc is _MISSING else choose(acc, value)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _keep_first(acc: Any, value: Any) -> Any:
|
|
430
|
+
return value if acc is _MISSING else acc
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _keep_last(_: Any, value: Any) -> Any:
|
|
434
|
+
return value
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _any(acc: Any, value: Any) -> bool:
|
|
438
|
+
return bool(acc) or bool(value)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _all(acc: Any, value: Any) -> bool:
|
|
442
|
+
return acc is not False and bool(value)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _append(acc: list[Any], value: Any) -> list[Any]:
|
|
446
|
+
acc.append(value)
|
|
447
|
+
|
|
448
|
+
return acc
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _add_to_set(acc: set[Any], value: Any) -> set[Any]:
|
|
452
|
+
acc.add(value)
|
|
453
|
+
|
|
454
|
+
return acc
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _add_to_counter(acc: Counter[Any], value: Any) -> Counter[Any]:
|
|
458
|
+
acc[value] += 1
|
|
459
|
+
|
|
460
|
+
return acc
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _most_common(acc: Counter[Any]) -> Any:
|
|
464
|
+
# `max` returns the first of equal counts, and a Counter keeps first-seen order.
|
|
465
|
+
return max(acc, key=acc.__getitem__) if acc else None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _welford(acc: tuple[int, Any, Any], value: Any) -> tuple[int, Any, Any]:
|
|
469
|
+
count, mean, squares = acc
|
|
470
|
+
count += 1
|
|
471
|
+
delta = value - mean
|
|
472
|
+
mean += delta / count
|
|
473
|
+
|
|
474
|
+
return (count, mean, squares + delta * (value - mean))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _variance(acc: tuple[int, Any, Any], *, sample: bool) -> Any:
|
|
478
|
+
count, _, squares = acc
|
|
479
|
+
degrees = count - 1 if sample else count
|
|
480
|
+
|
|
481
|
+
return squares / degrees if degrees > 0 else None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _stdev(acc: tuple[int, Any, Any], *, sample: bool) -> Any:
|
|
485
|
+
if (result := _variance(acc, sample=sample)) is None:
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
return result.sqrt() if isinstance(result, Decimal) else math.sqrt(result)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _percentile(acc: list[Any], *, at: float | Decimal) -> Any:
|
|
492
|
+
if not acc:
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
values = sorted(acc)
|
|
496
|
+
|
|
497
|
+
# `Decimal * float` raises; a float `at` like 0.9 is meant as the decimal 0.9.
|
|
498
|
+
if isinstance(at, float) and isinstance(values[0], Decimal):
|
|
499
|
+
at = Decimal(str(at))
|
|
500
|
+
|
|
501
|
+
position = at * (len(values) - 1)
|
|
502
|
+
index = min(math.floor(position), len(values) - 1)
|
|
503
|
+
weight = position - index
|
|
504
|
+
|
|
505
|
+
if weight == 0:
|
|
506
|
+
return values[index]
|
|
507
|
+
|
|
508
|
+
return values[index] + (values[index + 1] - values[index]) * weight
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _check_type(value: object, kind: type, /, *, role: str, name: str) -> None:
|
|
512
|
+
if not isinstance(value, kind):
|
|
513
|
+
msg = f"The {role} of aggregate '{name}' must be a {kind.__name__}, received {value!r}"
|
|
514
|
+
raise InvalidAggregateError(msg)
|