pyaggregation 0.1.0__tar.gz
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-0.1.0/PKG-INFO +663 -0
- pyaggregation-0.1.0/README.md +639 -0
- pyaggregation-0.1.0/pyproject.toml +112 -0
- pyaggregation-0.1.0/pyproject.toml.orig +79 -0
- pyaggregation-0.1.0/src/pyaggregation/__init__.py +96 -0
- pyaggregation-0.1.0/src/pyaggregation/aggregates.py +514 -0
- pyaggregation-0.1.0/src/pyaggregation/compilers.py +273 -0
- pyaggregation-0.1.0/src/pyaggregation/exceptions.py +51 -0
- pyaggregation-0.1.0/src/pyaggregation/json_schema.py +99 -0
- pyaggregation-0.1.0/src/pyaggregation/py.typed +0 -0
- pyaggregation-0.1.0/src/pyaggregation/report.py +232 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyaggregation
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Summaries over collections in one pass: total(salary) & count(where=is_manager()), definable as JSON.
|
|
5
|
+
Keywords: aggregation,kpi,report,fold,json,dsl,composition
|
|
6
|
+
Author: aaltatan
|
|
7
|
+
Author-email: aaltatan <a.altatan@gmail.com>
|
|
8
|
+
License-Expression: GPL-3.0-only
|
|
9
|
+
Classifier: Development Status :: 1 - Planning
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: pyvocabulary>=0.1.4
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Project-URL: Homepage, https://github.com/aaltatan/pyaggregation
|
|
21
|
+
Project-URL: Repository, https://github.com/aaltatan/pyaggregation
|
|
22
|
+
Project-URL: Issues, https://github.com/aaltatan/pyaggregation/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pyaggregation
|
|
26
|
+
|
|
27
|
+
Summaries over collections, computed in one pass. Composable with `&`, groupable by any key,
|
|
28
|
+
and definable as JSON.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
report = (
|
|
32
|
+
total(salary, name="payroll", start=Decimal(0))
|
|
33
|
+
& average(salary, name="average_salary")
|
|
34
|
+
& count(name="managers", where=is_manager)
|
|
35
|
+
& median(salary, name="median_salary")
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
report(employees)
|
|
39
|
+
# {'payroll': Decimal('19000.75'), 'average_salary': Decimal('3800.15'), 'managers': 2, ...}
|
|
40
|
+
|
|
41
|
+
report.group_by(department)(employees)
|
|
42
|
+
# {'Sales': {...}, 'IT': {...}}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A `Report` runs several aggregates together over **any iterable**: a list, a generator, a
|
|
46
|
+
database cursor. Each item is read exactly once, so a report over a million rows needs one
|
|
47
|
+
loop, not one loop per number.
|
|
48
|
+
|
|
49
|
+
| Piece | What it does |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| [Built-ins](#built-in-aggregates) | 19 aggregates: `total`, `count`, `average`, `median`, `percentile`, `stdev`, `mode`, `join`, ... |
|
|
52
|
+
| [`Report`](#composing-with-) | Aggregates joined with `&`; calling it returns one dict |
|
|
53
|
+
| [`group_by`](#grouping) | The same report per key, still in one pass |
|
|
54
|
+
| [`Fold` and `aggregate`](#your-own-aggregates) | Your own aggregate from three small functions |
|
|
55
|
+
| [`ReportCompiler`](#from-json) | Builds a report from JSON; errors carry a JSON path |
|
|
56
|
+
| [`get_report_json_schema`](#json-schema) | The JSON Schema of that JSON, for editors and API validation |
|
|
57
|
+
|
|
58
|
+
**When to use it.** Dashboards, KPI tiles, exports and API endpoints that summarise records,
|
|
59
|
+
especially when users choose the numbers (a report builder) or the input is too large to loop
|
|
60
|
+
over once per metric. **When not to.** If the data sits in a database and the summary is
|
|
61
|
+
fixed, a SQL `GROUP BY` is faster. For one number from a list, `sum()` is fine.
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv add pyaggregation
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
or, with pip:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install pyaggregation
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
It needs Python 3.12 or newer. The only runtime dependency is
|
|
76
|
+
[pyvocabulary](https://github.com/aaltatan/pyvocabulary). For JSON definitions,
|
|
77
|
+
[pyformula](https://github.com/aaltatan/pyformula) (values) and
|
|
78
|
+
[pyspecification](https://github.com/aaltatan/pyspecification) (conditions) fit the
|
|
79
|
+
compiler, but they are optional: any object with a `compile()` method works.
|
|
80
|
+
|
|
81
|
+
## Quick start
|
|
82
|
+
|
|
83
|
+
The examples below build on each other and are run by the test suite. A comment directly
|
|
84
|
+
under a line shows what that line outputs or raises.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from dataclasses import dataclass
|
|
88
|
+
from decimal import Decimal
|
|
89
|
+
|
|
90
|
+
from pyaggregation import average, count, maximum, median, minimum, total
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Employee:
|
|
95
|
+
name: str
|
|
96
|
+
department: str
|
|
97
|
+
salary: Decimal
|
|
98
|
+
manager: bool = False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
employees = [
|
|
102
|
+
Employee("Alice", "Sales", Decimal("3000.00"), manager=True),
|
|
103
|
+
Employee("Bob", "IT", Decimal("4500.50")),
|
|
104
|
+
Employee("Carol", "Sales", Decimal("2500.25")),
|
|
105
|
+
Employee("Dan", "IT", Decimal("5000.00"), manager=True),
|
|
106
|
+
Employee("Eve", "IT", Decimal("4000.00")),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def salary(employee: Employee) -> Decimal:
|
|
111
|
+
return employee.salary
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def department(employee: Employee) -> str:
|
|
115
|
+
return employee.department
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_manager(employee: Employee) -> bool:
|
|
119
|
+
return employee.manager
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
payroll = (
|
|
123
|
+
total(salary, name="payroll", start=Decimal(0))
|
|
124
|
+
& count(name="headcount")
|
|
125
|
+
& count(name="managers", where=is_manager)
|
|
126
|
+
& average(salary, name="average_salary")
|
|
127
|
+
& median(salary, name="median_salary")
|
|
128
|
+
& minimum(salary, name="lowest")
|
|
129
|
+
& maximum(salary, name="highest")
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
payroll
|
|
133
|
+
# Report(payroll, headcount, managers, average_salary, median_salary, lowest, highest)
|
|
134
|
+
|
|
135
|
+
payroll(employees)
|
|
136
|
+
# {'payroll': Decimal('19000.75'), 'headcount': 5, 'managers': 2, 'average_salary':
|
|
137
|
+
# Decimal('3800.15'), 'median_salary': Decimal('4000.00'), 'lowest': Decimal('2500.25'),
|
|
138
|
+
# 'highest': Decimal('5000.00')}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Every aggregate takes a `name` (its key in the result) and most take:
|
|
142
|
+
|
|
143
|
+
- `of`: what to aggregate for each item. Any callable: a function, a lambda,
|
|
144
|
+
`operator.attrgetter("salary")`, or a pyformula `Formula`.
|
|
145
|
+
- `where`: which items to include. Any callable returning something truthy, such as a
|
|
146
|
+
pyspecification `Predicate`. It runs before `of`, so `of` never sees an item it
|
|
147
|
+
would fail on.
|
|
148
|
+
|
|
149
|
+
## Composing with `&`
|
|
150
|
+
|
|
151
|
+
`&` joins reports into a new one; the originals are unchanged. Results come back in the
|
|
152
|
+
order you joined them.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from pyaggregation import share
|
|
156
|
+
|
|
157
|
+
headline = total(salary, name="payroll", start=Decimal(0)) & count(name="headcount")
|
|
158
|
+
|
|
159
|
+
with_shares = headline & share(name="manager_share", where=is_manager)
|
|
160
|
+
|
|
161
|
+
headline(employees)
|
|
162
|
+
# {'payroll': Decimal('19000.75'), 'headcount': 5}
|
|
163
|
+
|
|
164
|
+
with_shares(employees)
|
|
165
|
+
# {'payroll': Decimal('19000.75'), 'headcount': 5, 'manager_share': 0.4}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
A name used twice is rejected when the report is built, not when the first row arrives:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
headline & count(name="payroll")
|
|
172
|
+
# DuplicateResultNameError: Result name 'payroll' is used more than once. Result names: payroll,
|
|
173
|
+
# headcount
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
So is a name that cannot be a JSON key or Python identifier:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
count(name="head count")
|
|
180
|
+
# InvalidResultNameError: Invalid result name: 'head count', you should follow the python
|
|
181
|
+
# variable naming convention: name must start with a letter and can only contain letters,
|
|
182
|
+
# numbers and underscores
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Filtering with `where`
|
|
186
|
+
|
|
187
|
+
`where` filters each aggregate separately, so one pass can compare groups side by side:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
by_role = (
|
|
191
|
+
average(salary, name="manager_average", where=is_manager)
|
|
192
|
+
& average(salary, name="staff_average", where=lambda e: not e.manager)
|
|
193
|
+
& count(name="well_paid", where=lambda e: e.salary >= 4000)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
by_role(employees)
|
|
197
|
+
# {'manager_average': Decimal('4000.00'), 'staff_average':
|
|
198
|
+
# Decimal('3666.916666666666666666666667'), 'well_paid': 3}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`share` is the one aggregate where `where` does not filter: it splits items into a part and a
|
|
202
|
+
whole. Without `of` it is a share of items; with `of` it is a share of a total.
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
shares = share(name="of_people", where=is_manager) & share(
|
|
206
|
+
name="of_payroll", where=is_manager, of=salary
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
shares(employees)
|
|
210
|
+
# {'of_people': 0.4, 'of_payroll': Decimal('0.4210360117363788271515598068')}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Grouping
|
|
214
|
+
|
|
215
|
+
`group_by(key)` returns a new report that folds each key separately, still in one pass.
|
|
216
|
+
Groups come back in the order their first item appeared.
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
by_department = payroll.group_by(department)
|
|
220
|
+
|
|
221
|
+
by_department
|
|
222
|
+
# Report(payroll, headcount, managers, average_salary, median_salary, lowest,
|
|
223
|
+
# highest).group_by(department)
|
|
224
|
+
|
|
225
|
+
by_department(employees)["IT"]
|
|
226
|
+
# {'payroll': Decimal('13500.50'), 'headcount': 3, 'managers': 1, 'average_salary':
|
|
227
|
+
# Decimal('4500.166666666666666666666667'), 'median_salary': Decimal('4500.50'), 'lowest':
|
|
228
|
+
# Decimal('4000.00'), 'highest': Decimal('5000.00')}
|
|
229
|
+
|
|
230
|
+
list(by_department(employees))
|
|
231
|
+
# ['Sales', 'IT']
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Group by several fields with a key that returns a tuple:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
count(name="people").group_by(lambda e: (e.department, e.manager))(employees)
|
|
238
|
+
# {('Sales', True): {'people': 1}, ('IT', False): {'people': 2}, ('Sales', False): {'people':
|
|
239
|
+
# 1}, ('IT', True): {'people': 1}}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Empty input has no groups, because a group exists only once an item has that key:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
by_department([])
|
|
246
|
+
# {}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Built-in aggregates
|
|
250
|
+
|
|
251
|
+
| Aggregate | Result | No values give | Memory |
|
|
252
|
+
| --- | --- | --- | --- |
|
|
253
|
+
| `total(of, start=0)` | sum of values | `start` | constant |
|
|
254
|
+
| `product(of, start=1)` | product of values | `start` | constant |
|
|
255
|
+
| `count()` | number of items that pass `where` | `0` | constant |
|
|
256
|
+
| `count_distinct(of)` | number of different values | `0` | one entry per distinct value |
|
|
257
|
+
| `average(of)` | mean | `None` | constant |
|
|
258
|
+
| `share(where, of=None)` | part of the items, or of the total of `of` | `None` | constant |
|
|
259
|
+
| `variance(of, sample=True)` | sample (or population) variance | `None` | constant |
|
|
260
|
+
| `stdev(of, sample=True)` | sample (or population) standard deviation | `None` | constant |
|
|
261
|
+
| `minimum(of)` / `maximum(of)` | smallest / largest value | `None` | constant |
|
|
262
|
+
| `median(of)` | middle value, or mean of the middle two | `None` | every value |
|
|
263
|
+
| `percentile(of, at=0.9)` | value at a fraction of the sorted values | `None` | every value |
|
|
264
|
+
| `mode(of)` | most common value, first seen on ties | `None` | one entry per distinct value |
|
|
265
|
+
| `first(of)` / `last(of)` | first / last value in input order | `None` | constant |
|
|
266
|
+
| `some(of)` / `every(of)` | whether any / all values are truthy | `None` | constant |
|
|
267
|
+
| `collect(of)` | list of values in input order | `[]` | every value |
|
|
268
|
+
| `join(of, separator=", ")` | string values joined | `""` | every value |
|
|
269
|
+
|
|
270
|
+
All of them also take `name` (required) and `where` (optional).
|
|
271
|
+
|
|
272
|
+
### Statistics
|
|
273
|
+
|
|
274
|
+
`variance` and `stdev` match Python's `statistics` module: `sample=True` (the default) is
|
|
275
|
+
`statistics.variance`, `sample=False` is `statistics.pvariance`. They use Welford's
|
|
276
|
+
one-pass method, which stays accurate even when values share a large offset.
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
from pyaggregation import percentile, stdev, variance
|
|
280
|
+
|
|
281
|
+
spread = (
|
|
282
|
+
stdev(salary, name="sample_stdev")
|
|
283
|
+
& stdev(salary, name="population_stdev", sample=False)
|
|
284
|
+
& percentile(salary, name="p25", at=0.25)
|
|
285
|
+
& percentile(salary, name="p90", at=0.9)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
spread(employees)
|
|
289
|
+
# {'sample_stdev': Decimal('1036.828119796140559902272581'), 'population_stdev':
|
|
290
|
+
# Decimal('927.3672627389862741638917628'), 'p25': Decimal('3000.00'), 'p90':
|
|
291
|
+
# Decimal('4800.200')}
|
|
292
|
+
|
|
293
|
+
variance(float, name="var")([1e9 + 4, 1e9 + 7, 1e9 + 13, 1e9 + 16])
|
|
294
|
+
# {'var': 30.0}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`percentile` interpolates linearly between the two nearest values, like SQL's
|
|
298
|
+
`PERCENTILE_CONT` and NumPy's default. `at=0` is the minimum, `at=1` the maximum and
|
|
299
|
+
`at=0.5` the median. `at` is checked when the report is built:
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
percentile(salary, name="p", at=90)
|
|
303
|
+
# InvalidAggregateError: The at of aggregate 'p' must be a number from 0 to 1, received 90
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Order, truth and collections
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
from pyaggregation import collect, count_distinct, every, first, join, last, mode, some
|
|
310
|
+
|
|
311
|
+
people = (
|
|
312
|
+
first(lambda e: e.name, name="first_hired")
|
|
313
|
+
& last(lambda e: e.name, name="last_hired")
|
|
314
|
+
& count_distinct(department, name="departments")
|
|
315
|
+
& mode(department, name="largest_department")
|
|
316
|
+
& some(is_manager, name="has_managers")
|
|
317
|
+
& every(lambda e: e.salary > 2000, name="all_above_2000")
|
|
318
|
+
& collect(lambda e: e.name, name="managers", where=is_manager)
|
|
319
|
+
& join(lambda e: e.name, name="team", separator=" / ")
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
people(employees)
|
|
323
|
+
# {'first_hired': 'Alice', 'last_hired': 'Eve', 'departments': 2, 'largest_department': 'IT',
|
|
324
|
+
# 'has_managers': True, 'all_above_2000': True, 'managers': ['Alice', 'Dan'], 'team': 'Alice /
|
|
325
|
+
# Bob / Carol / Dan / Eve'}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`every` of nothing is `None`, not `True`, so "nothing to check" never reads as "everything
|
|
329
|
+
passed":
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
every(is_manager, name="all_managers")([])
|
|
333
|
+
# {'all_managers': None}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Money and `Decimal`
|
|
337
|
+
|
|
338
|
+
Values pass through unchanged, so `Decimal` stays `Decimal` and nothing is ever rounded:
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
average(salary, name="average")(employees)
|
|
342
|
+
# {'average': Decimal('3800.15')}
|
|
343
|
+
|
|
344
|
+
percentile(salary, name="p90", at=0.9)(employees)
|
|
345
|
+
# {'p90': Decimal('4800.200')}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
A float `at` such as `0.9` with `Decimal` values is read as the decimal `0.9`, so the
|
|
349
|
+
percentile stays exact.
|
|
350
|
+
|
|
351
|
+
Pass `start=Decimal(0)` to `total` so that an empty total is `Decimal('0')` and not the
|
|
352
|
+
integer `0`:
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
total(salary, name="payroll")([])
|
|
356
|
+
# {'payroll': 0}
|
|
357
|
+
|
|
358
|
+
total(salary, name="payroll", start=Decimal(0))([])
|
|
359
|
+
# {'payroll': Decimal('0')}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Mixing `Decimal` with `float` raises instead of quietly losing precision:
|
|
363
|
+
|
|
364
|
+
```python
|
|
365
|
+
total(lambda e: float(e.salary), name="payroll", start=Decimal(0))(employees)
|
|
366
|
+
# TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Rounding is your job, once, at the end:
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
average(salary, name="average")(employees)["average"].quantize(Decimal("0.01"))
|
|
373
|
+
# Decimal('3800.15')
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Edge cases
|
|
377
|
+
|
|
378
|
+
**Empty or fully filtered input.** A report always returns every key. Sums and collections
|
|
379
|
+
give their empty value; everything else gives `None`, never an exception or `NaN`:
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
from pyaggregation import product
|
|
383
|
+
|
|
384
|
+
everything = (
|
|
385
|
+
total(salary, name="total", start=Decimal(0))
|
|
386
|
+
& product(salary, name="product")
|
|
387
|
+
& count(name="count")
|
|
388
|
+
& average(salary, name="average")
|
|
389
|
+
& median(salary, name="median")
|
|
390
|
+
& stdev(salary, name="stdev")
|
|
391
|
+
& collect(salary, name="collect")
|
|
392
|
+
& join(lambda e: e.name, name="join")
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
everything([])
|
|
396
|
+
# {'total': Decimal('0'), 'product': 1, 'count': 0, 'average': None, 'median': None, 'stdev':
|
|
397
|
+
# None, 'collect': [], 'join': ''}
|
|
398
|
+
|
|
399
|
+
everything(employees[:1])["stdev"] is None
|
|
400
|
+
# True
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
A sample standard deviation needs two values, so one employee gives `None`.
|
|
404
|
+
|
|
405
|
+
**`None` values are not skipped.** Unlike SQL's `NULL`, a `None` is a value. Arithmetic on
|
|
406
|
+
it fails, with a note naming the aggregate:
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
bonuses = [Decimal("100"), None, Decimal("250")]
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
total(lambda bonus: bonus, name="bonuses")(bonuses)
|
|
413
|
+
except TypeError as error:
|
|
414
|
+
print(error, error.__notes__)
|
|
415
|
+
# unsupported operand type(s) for +: 'decimal.Decimal' and 'NoneType' ["in aggregate 'bonuses'"]
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Skip them explicitly with `where`:
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
total(lambda bonus: bonus, name="bonuses", where=lambda bonus: bonus is not None)(bonuses)
|
|
422
|
+
# {'bonuses': Decimal('350')}
|
|
423
|
+
|
|
424
|
+
first(lambda bonus: bonus, name="first_missing", where=lambda bonus: bonus is None)(bonuses)
|
|
425
|
+
# {'first_missing': None}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
**Errors keep their type.** Whatever `of`, `where` or a fold raises comes through
|
|
429
|
+
unchanged, with only an `in aggregate '...'` note added, so your `except` clauses still work.
|
|
430
|
+
|
|
431
|
+
**Generators are read once.** A report reads its input exactly once, so a generator or
|
|
432
|
+
cursor works, and a spent one gives an empty report:
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
def rows():
|
|
436
|
+
yield from employees # a database cursor works the same way
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
stream = rows()
|
|
440
|
+
|
|
441
|
+
count(name="headcount")(stream)
|
|
442
|
+
# {'headcount': 5}
|
|
443
|
+
|
|
444
|
+
count(name="headcount")(stream)
|
|
445
|
+
# {'headcount': 0}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
**Equal values.** `count_distinct` follows Python equality, so `1`, `1.0`, `Decimal(1)` and
|
|
449
|
+
`True` are one value. `mode` gives ties to the value seen first:
|
|
450
|
+
|
|
451
|
+
```python
|
|
452
|
+
count_distinct(lambda value: value, name="distinct")([1, 1.0, Decimal(1), True, 2])
|
|
453
|
+
# {'distinct': 2}
|
|
454
|
+
|
|
455
|
+
mode(lambda value: value, name="mode")(["b", "a", "a", "b"])
|
|
456
|
+
# {'mode': 'b'}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## Your own aggregates
|
|
460
|
+
|
|
461
|
+
A `Fold` is three plain functions: `initial()` makes the starting value, `step(acc, value)`
|
|
462
|
+
adds one value, `finish(acc)` turns the result into the answer. `aggregate` names it and adds
|
|
463
|
+
`of` and `where`, and the result joins with `&` like any built-in.
|
|
464
|
+
|
|
465
|
+
```python
|
|
466
|
+
from pyaggregation import Fold, aggregate
|
|
467
|
+
|
|
468
|
+
salary_range = Fold(
|
|
469
|
+
initial=lambda: None,
|
|
470
|
+
step=lambda acc, value: (
|
|
471
|
+
(value, value) if acc is None else (min(acc[0], value), max(acc[1], value))
|
|
472
|
+
),
|
|
473
|
+
finish=lambda acc: None if acc is None else acc[1] - acc[0],
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
with_range = payroll & aggregate(salary_range, name="salary_range", of=salary)
|
|
477
|
+
|
|
478
|
+
with_range(employees)["salary_range"]
|
|
479
|
+
# Decimal('2499.75')
|
|
480
|
+
|
|
481
|
+
with_range.group_by(department)(employees)["Sales"]["salary_range"]
|
|
482
|
+
# Decimal('499.75')
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
`initial` is a function, not a value, so a fold may start from a list or a set: every run
|
|
486
|
+
gets a fresh one.
|
|
487
|
+
|
|
488
|
+
## From JSON
|
|
489
|
+
|
|
490
|
+
A report can be stored or sent as JSON, for example from a report builder in your UI.
|
|
491
|
+
`aggregates` is a list, so a duplicate name is an error and not silently dropped:
|
|
492
|
+
|
|
493
|
+
```json
|
|
494
|
+
{
|
|
495
|
+
"aggregates": [
|
|
496
|
+
{"name": "payroll", "fold": "total", "of": "pay"},
|
|
497
|
+
{"name": "yearly", "fold": "total", "of": {"operator": "multiply", "expressions": ["pay", 12]}},
|
|
498
|
+
{"name": "managers", "fold": "count", "where": "is_manager"},
|
|
499
|
+
{"name": "p90", "fold": "percentile", "of": "pay", "at": 0.9},
|
|
500
|
+
{"name": "team", "fold": "join", "of": "name", "separator": ", "}
|
|
501
|
+
],
|
|
502
|
+
"group_by": "department"
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
`ReportCompiler` does not know what is inside `of`, `where` and `group_by`; it hands them to
|
|
507
|
+
compilers you pass in. Anything with a `compile(definition)` method works. Here pyformula
|
|
508
|
+
compiles the values (so `of` can be arithmetic), and a tiny lookup compiles conditions and
|
|
509
|
+
keys:
|
|
510
|
+
|
|
511
|
+
```python
|
|
512
|
+
import json
|
|
513
|
+
|
|
514
|
+
from pyformula import FormulaCompiler, variable
|
|
515
|
+
|
|
516
|
+
from pyaggregation import ReportCompiler
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@variable()
|
|
520
|
+
def pay(employee: Employee) -> Decimal:
|
|
521
|
+
return employee.salary
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class Named:
|
|
525
|
+
"""The smallest compiler: a JSON string names a Python callable."""
|
|
526
|
+
|
|
527
|
+
def __init__(self, **entries):
|
|
528
|
+
self.entries = entries
|
|
529
|
+
|
|
530
|
+
def compile(self, definition):
|
|
531
|
+
return self.entries[definition]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
compiler = ReportCompiler(
|
|
535
|
+
FormulaCompiler[Employee]({"pay": pay, "name": lambda e: e.name}),
|
|
536
|
+
Named(is_manager=is_manager),
|
|
537
|
+
Named(department=department),
|
|
538
|
+
start=Decimal(0),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
definition = json.loads(
|
|
542
|
+
"""
|
|
543
|
+
{
|
|
544
|
+
"aggregates": [
|
|
545
|
+
{"name": "payroll", "fold": "total", "of": "pay"},
|
|
546
|
+
{"name": "yearly", "fold": "total",
|
|
547
|
+
"of": {"operator": "multiply", "expressions": ["pay", 12]}},
|
|
548
|
+
{"name": "managers", "fold": "count", "where": "is_manager"},
|
|
549
|
+
{"name": "p90", "fold": "percentile", "of": "pay", "at": 0.9}
|
|
550
|
+
],
|
|
551
|
+
"group_by": "department"
|
|
552
|
+
}
|
|
553
|
+
""",
|
|
554
|
+
parse_float=Decimal,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
report = compiler.compile(definition)
|
|
558
|
+
|
|
559
|
+
report
|
|
560
|
+
# Report(payroll, yearly, managers, p90).group_by(department)
|
|
561
|
+
|
|
562
|
+
report(employees)["IT"]
|
|
563
|
+
# {'payroll': Decimal('13500.50'), 'yearly': Decimal('162006.00'), 'managers': 1, 'p90':
|
|
564
|
+
# Decimal('4900.100')}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
- `start` is set once on the compiler, because JSON has no `Decimal`. Use
|
|
568
|
+
`parse_float=Decimal` too, so numbers inside the JSON (such as `at`) stay exact.
|
|
569
|
+
- Without a third (keys) compiler, a definition with `group_by` is rejected, so an API can
|
|
570
|
+
offer ungrouped reports only.
|
|
571
|
+
- For conditions, pyspecification's `PredicateCompiler` fits the same slot and lets users
|
|
572
|
+
write `{"name": "salary__ge", "args": [4000]}`-style rules.
|
|
573
|
+
|
|
574
|
+
### Errors point at the JSON
|
|
575
|
+
|
|
576
|
+
Every problem is found when compiling, before any item is read. Shape errors say where:
|
|
577
|
+
|
|
578
|
+
```python
|
|
579
|
+
compiler.compile({"aggregates": [{"name": "p", "fold": "percentile", "of": "pay"}]})
|
|
580
|
+
# InvalidReportDefinitionError: Invalid report at $.aggregates[0]: missing 'at'
|
|
581
|
+
|
|
582
|
+
compiler.compile({"aggregates": [{"name": "n", "fold": "count", "of": "pay"}]})
|
|
583
|
+
# InvalidReportDefinitionError: Invalid report at $.aggregates[0]: unknown 'of'. Allowed keys:
|
|
584
|
+
# fold, name, where
|
|
585
|
+
|
|
586
|
+
compiler.compile({"aggregates": [{"name": "n", "fold": "average_of"}]})
|
|
587
|
+
# InvalidReportDefinitionError: Invalid report at $.aggregates[0].fold: unknown fold
|
|
588
|
+
# 'average_of'. Available folds: average, collect, count, count_distinct, every, first, join,
|
|
589
|
+
# last, maximum, median, minimum, mode, percentile, product, share, some, stdev, total, variance
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
Errors from your compilers keep their type and get the path as a note:
|
|
593
|
+
|
|
594
|
+
```python
|
|
595
|
+
try:
|
|
596
|
+
compiler.compile({"aggregates": [{"name": "n", "fold": "count", "where": "is_boss"}]})
|
|
597
|
+
except KeyError as error:
|
|
598
|
+
print(repr(error), error.__notes__)
|
|
599
|
+
# KeyError('is_boss') ['at $.aggregates[0].where']
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### JSON Schema
|
|
603
|
+
|
|
604
|
+
`get_report_json_schema` describes exactly what the compiler accepts, for form builders,
|
|
605
|
+
editor autocompletion or request validation. Pass the schemas of your `of`, `where` and
|
|
606
|
+
(optionally) `group_by` languages; each is embedded under its own `$id`.
|
|
607
|
+
|
|
608
|
+
```python
|
|
609
|
+
from pyaggregation import get_report_json_schema
|
|
610
|
+
|
|
611
|
+
schema = get_report_json_schema(
|
|
612
|
+
{"type": "string"},
|
|
613
|
+
{"enum": ["is_manager"]},
|
|
614
|
+
{"enum": ["department"]},
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
schema["$defs"]["percentile"]["required"]
|
|
618
|
+
# ['name', 'fold', 'at', 'of']
|
|
619
|
+
|
|
620
|
+
schema["$defs"]["percentile"]["properties"]["at"]
|
|
621
|
+
# {'type': 'number', 'minimum': 0, 'maximum': 1}
|
|
622
|
+
|
|
623
|
+
schema["properties"]["aggregates"]["minItems"]
|
|
624
|
+
# 1
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
Each fold has its own variant, so `of`, `where` and options such as `at` are required or
|
|
628
|
+
forbidden exactly as the compiler requires or forbids them.
|
|
629
|
+
|
|
630
|
+
## Errors
|
|
631
|
+
|
|
632
|
+
| Exception | Raised when |
|
|
633
|
+
| --- | --- |
|
|
634
|
+
| `PyaggregationError` | Base class of everything below |
|
|
635
|
+
| `InvalidResultNameError` | A `name` is not a Python identifier (also a `ValueError`) |
|
|
636
|
+
| `DuplicateResultNameError` | Two aggregates in one report share a name |
|
|
637
|
+
| `InvalidAggregateError` | A non-callable `of`/`where`, a bad `at`, `sample` or `separator`, an empty report |
|
|
638
|
+
| `InvalidGroupKeyError` | The `group_by` key is not callable |
|
|
639
|
+
| `InvalidReportDefinitionError` | A JSON definition has the wrong shape (the message has the path) |
|
|
640
|
+
|
|
641
|
+
Everything raised while a report *runs* (by your `of`, `where`, key or fold) keeps its own
|
|
642
|
+
type and gets an `in aggregate '...'` note.
|
|
643
|
+
|
|
644
|
+
## Development
|
|
645
|
+
|
|
646
|
+
```bash
|
|
647
|
+
uv sync
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
```bash
|
|
651
|
+
uv run pytest --cov --cov-branch
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
```bash
|
|
655
|
+
uvx ruff check . && uvx ruff format --check . && uvx pyright src
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
See [`docs/DESIGN.md`](docs/DESIGN.md) for the decisions behind the API and
|
|
659
|
+
[`PHASES.md`](PHASES.md) for the plan.
|
|
660
|
+
|
|
661
|
+
## License
|
|
662
|
+
|
|
663
|
+
GPL-3.0-only.
|