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,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)