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