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,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.
@@ -0,0 +1,10 @@
1
+ pyaggregation/__init__.py,sha256=kaVC8lBmY7VnIX4vnfZE22c8qunvK3HZXR8ng5Dh5fY,1888
2
+ pyaggregation/aggregates.py,sha256=KSI-jB3d09G02L2aIbCHaCRoO8bsE84gbbr1BpoJ-50,14060
3
+ pyaggregation/compilers.py,sha256=GLL7iupU84ty5KTW-eN7HL0iNFP1goTCI2RBua0u-sk,8845
4
+ pyaggregation/exceptions.py,sha256=37tRPApGbdU3moMP6DMvbX7lgdEF5HsBwKVmZhJXUKo,1664
5
+ pyaggregation/json_schema.py,sha256=PxEYebjVTOaeVd7os9__A3PfMR9YwMPam06tbZbeo9Y,3423
6
+ pyaggregation/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pyaggregation/report.py,sha256=ZtHCnERnR-FaF6rqPLugPIe7IG8hflnSR8rU8Vp-LLA,7811
8
+ pyaggregation-0.1.0.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
9
+ pyaggregation-0.1.0.dist-info/METADATA,sha256=BIjodfn5Cj82RW4xCwZko3ftnAmOYChlN7uej9k-IeU,21895
10
+ pyaggregation-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any