morphir-python 0.0.1__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.
Files changed (99) hide show
  1. morphir/__init__.py +14 -0
  2. morphir/ir/__init__.py +110 -0
  3. morphir/ir/access_controlled.py +15 -0
  4. morphir/ir/decorations.py +19 -0
  5. morphir/ir/diagnostics.py +105 -0
  6. morphir/ir/distribution.py +21 -0
  7. morphir/ir/document.py +37 -0
  8. morphir/ir/documented.py +11 -0
  9. morphir/ir/format_version.py +483 -0
  10. morphir/ir/fqname.py +9 -0
  11. morphir/ir/json.py +11 -0
  12. morphir/ir/literal.py +25 -0
  13. morphir/ir/meta.py +11 -0
  14. morphir/ir/migrate.py +1267 -0
  15. morphir/ir/module.py +17 -0
  16. morphir/ir/name.py +27 -0
  17. morphir/ir/package.py +15 -0
  18. morphir/ir/path.py +25 -0
  19. morphir/ir/qname.py +9 -0
  20. morphir/ir/ref.py +17 -0
  21. morphir/ir/type.py +37 -0
  22. morphir/ir/type_constraints.py +33 -0
  23. morphir/ir/type_def.py +19 -0
  24. morphir/ir/type_spec.py +25 -0
  25. morphir/ir/v3/__init__.py +64 -0
  26. morphir/ir/v3/_legacy_value.py +201 -0
  27. morphir/ir/v3/access_controlled.py +31 -0
  28. morphir/ir/v3/codec.py +1275 -0
  29. morphir/ir/v3/codec_v1.py +491 -0
  30. morphir/ir/v3/codec_v2.py +187 -0
  31. morphir/ir/v3/distribution.py +42 -0
  32. morphir/ir/v3/documented.py +13 -0
  33. morphir/ir/v3/fqname.py +30 -0
  34. morphir/ir/v3/literal.py +46 -0
  35. morphir/ir/v3/module.py +32 -0
  36. morphir/ir/v3/name.py +42 -0
  37. morphir/ir/v3/package.py +23 -0
  38. morphir/ir/v3/path.py +29 -0
  39. morphir/ir/v3/qname.py +16 -0
  40. morphir/ir/v3/type.py +126 -0
  41. morphir/ir/v3/value.py +235 -0
  42. morphir/ir/v4/__init__.py +160 -0
  43. morphir/ir/v4/access_controlled.py +17 -0
  44. morphir/ir/v4/annotation.py +75 -0
  45. morphir/ir/v4/codec.py +2161 -0
  46. morphir/ir/v4/decorations.py +88 -0
  47. morphir/ir/v4/distribution.py +56 -0
  48. morphir/ir/v4/document.py +69 -0
  49. morphir/ir/v4/documented.py +10 -0
  50. morphir/ir/v4/fqname.py +69 -0
  51. morphir/ir/v4/incompleteness.py +78 -0
  52. morphir/ir/v4/json.py +103 -0
  53. morphir/ir/v4/literal.py +51 -0
  54. morphir/ir/v4/meta.py +27 -0
  55. morphir/ir/v4/module.py +30 -0
  56. morphir/ir/v4/name.py +69 -0
  57. morphir/ir/v4/package.py +19 -0
  58. morphir/ir/v4/path.py +50 -0
  59. morphir/ir/v4/qname.py +34 -0
  60. morphir/ir/v4/ref.py +35 -0
  61. morphir/ir/v4/type.py +140 -0
  62. morphir/ir/v4/type_constraints.py +91 -0
  63. morphir/ir/v4/type_def.py +38 -0
  64. morphir/ir/v4/type_spec.py +64 -0
  65. morphir/ir/v4/value.py +480 -0
  66. morphir/ir/value.py +81 -0
  67. morphir/py.typed +0 -0
  68. morphir/sdk/__init__.py +110 -0
  69. morphir/sdk/_compare.py +187 -0
  70. morphir/sdk/aggregate.py +421 -0
  71. morphir/sdk/basics.py +504 -0
  72. morphir/sdk/char.py +110 -0
  73. morphir/sdk/decimal.py +311 -0
  74. morphir/sdk/dict.py +336 -0
  75. morphir/sdk/instant.py +118 -0
  76. morphir/sdk/int.py +86 -0
  77. morphir/sdk/key.py +522 -0
  78. morphir/sdk/list.py +356 -0
  79. morphir/sdk/local_date.py +436 -0
  80. morphir/sdk/local_time.py +267 -0
  81. morphir/sdk/maybe.py +135 -0
  82. morphir/sdk/number.py +237 -0
  83. morphir/sdk/regex.py +287 -0
  84. morphir/sdk/result.py +162 -0
  85. morphir/sdk/result_list.py +169 -0
  86. morphir/sdk/rule.py +103 -0
  87. morphir/sdk/set.py +214 -0
  88. morphir/sdk/stateful_app.py +49 -0
  89. morphir/sdk/string.py +427 -0
  90. morphir/sdk/tuple.py +43 -0
  91. morphir/sdk/uuid.py +209 -0
  92. morphir_python-0.0.1.dist-info/METADATA +269 -0
  93. morphir_python-0.0.1.dist-info/RECORD +99 -0
  94. morphir_python-0.0.1.dist-info/WHEEL +4 -0
  95. morphir_python-0.0.1.dist-info/entry_points.txt +2 -0
  96. morphir_python-0.0.1.dist-info/licenses/LICENSE +201 -0
  97. morphir_tools/__init__.py +13 -0
  98. morphir_tools/cli.py +223 -0
  99. morphir_tools/py.typed +0 -0
@@ -0,0 +1,187 @@
1
+ """Ordering and structural equality shared by the SDK modules.
2
+
3
+ Elm's `compare` works on the comparable types only: `Int`, `Float`, `Char`,
4
+ `String`, and tuples or lists of comparables. The Elm type checker rejects
5
+ anything else. Python has no such check, so `compare` raises `TypeError` at run
6
+ time instead.
7
+ """
8
+
9
+ import dataclasses
10
+ from enum import Enum
11
+ from functools import cmp_to_key
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable, Sequence
16
+
17
+
18
+ class Order(Enum):
19
+ """The result of a comparison: less than, equal or greater than."""
20
+
21
+ LT = "LT"
22
+ EQ = "EQ"
23
+ GT = "GT"
24
+
25
+
26
+ type Comparable = int | float | str | Sequence[Comparable]
27
+ """The Elm comparable types: numbers, strings, and sequences of comparables."""
28
+
29
+
30
+ def _is_number(value: object) -> bool:
31
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
32
+
33
+
34
+ def _order(a: Any, b: Any) -> Order:
35
+ if a < b:
36
+ return Order.LT
37
+ if a > b:
38
+ return Order.GT
39
+ return Order.EQ
40
+
41
+
42
+ def compare(a: object, b: object) -> Order:
43
+ """Compare two comparable values.
44
+
45
+ Numbers compare by value and strings by code point. Tuples and lists compare
46
+ element by element; when one is a prefix of the other, the shorter one is
47
+ less.
48
+
49
+ Args:
50
+ a: The first value.
51
+ b: The second value.
52
+
53
+ Returns:
54
+ `Order.LT`, `Order.EQ` or `Order.GT`.
55
+
56
+ Raises:
57
+ TypeError: If the values are not comparable, or not of the same kind
58
+ (for example an `int` and a `str`, or two `bool` values).
59
+ """
60
+ if _is_number(a) and _is_number(b):
61
+ return _order(a, b)
62
+ if isinstance(a, str) and isinstance(b, str):
63
+ return _order(a, b)
64
+ if isinstance(a, (tuple, list)) and isinstance(b, (tuple, list)):
65
+ for x, y in zip(a, b, strict=False):
66
+ order = compare(x, y)
67
+ if order is not Order.EQ:
68
+ return order
69
+ return _order(len(a), len(b))
70
+ raise TypeError(
71
+ f"compare: values are not comparable "
72
+ f"({type(a).__name__} and {type(b).__name__})"
73
+ )
74
+
75
+
76
+ def to_int(order: Order) -> int:
77
+ """Convert an `Order` to the -1, 0 or 1 that Python sort functions expect."""
78
+ if order is Order.LT:
79
+ return -1
80
+ if order is Order.GT:
81
+ return 1
82
+ return 0
83
+
84
+
85
+ def sort_key[A](by: Callable[[A, A], Order]) -> Callable[[A], Any]:
86
+ """Build a `key` function for `sorted` from a function that yields an `Order`."""
87
+ return cmp_to_key(lambda a, b: to_int(by(a, b)))
88
+
89
+
90
+ def search[K: Comparable, T](
91
+ key: K, items: Sequence[T], key_of: Callable[[T], K]
92
+ ) -> tuple[bool, int]:
93
+ """Find a key in a sequence of items sorted by key in ascending order.
94
+
95
+ Args:
96
+ key: The key to find.
97
+ items: The sorted items, with no key twice.
98
+ key_of: A function that gives the key of an item.
99
+
100
+ Returns:
101
+ A pair `(found, index)`. When `found` is true, `index` is the position
102
+ of the item with the key. Otherwise `index` is the position where the
103
+ key must go to keep the sequence sorted.
104
+
105
+ Raises:
106
+ TypeError: If the key is not comparable with the keys in the sequence.
107
+ """
108
+ low = 0
109
+ high = len(items)
110
+ while low < high:
111
+ middle = (low + high) // 2
112
+ order = compare(key_of(items[middle]), key)
113
+ if order is Order.EQ:
114
+ return (True, middle)
115
+ if order is Order.LT:
116
+ low = middle + 1
117
+ else:
118
+ high = middle
119
+ return (False, low)
120
+
121
+
122
+ def freeze(value: Any) -> Any:
123
+ """Turn every list inside a comparable value into a tuple.
124
+
125
+ `Dict` and `Set` keep their keys in sorted order. A list key that changed
126
+ after it was stored would break that order, and a list cannot be hashed. A
127
+ stored key is therefore always the tuple form. A value with no list in it is
128
+ returned as it is.
129
+ """
130
+ if isinstance(value, list):
131
+ return tuple(freeze(item) for item in value)
132
+ if isinstance(value, tuple):
133
+ frozen = tuple(freeze(item) for item in value)
134
+ if all(new is old for new, old in zip(frozen, value, strict=True)):
135
+ return value
136
+ return frozen
137
+ return value
138
+
139
+
140
+ def check_strictly_ascending(keys: Sequence[Any], owner: str) -> None:
141
+ """Check that keys are sorted in ascending order and that none repeats.
142
+
143
+ Raises:
144
+ ValueError: If a key is not greater than the key before it.
145
+ TypeError: If two keys are not comparable with each other.
146
+ """
147
+ for index in range(1, len(keys)):
148
+ if compare(keys[index - 1], keys[index]) is not Order.LT:
149
+ raise ValueError(
150
+ f"{owner}: entries must be sorted by key with no key twice; "
151
+ f"use from_list to build one from entries in any order"
152
+ )
153
+
154
+
155
+ def _contains_function(value: object) -> bool:
156
+ if isinstance(value, (tuple, list)):
157
+ return any(_contains_function(item) for item in value)
158
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
159
+ return any(
160
+ _contains_function(getattr(value, field.name))
161
+ for field in dataclasses.fields(value)
162
+ )
163
+ return callable(value) and not isinstance(value, type)
164
+
165
+
166
+ def equal(a: object, b: object) -> bool:
167
+ """Check structural equality, like Elm's `==`.
168
+
169
+ Frozen dataclasses, tuples, strings, numbers, `Decimal`, `Dict` and `Set`
170
+ values all compare by value in Python, so this function uses `==`. `NaN` is
171
+ not equal to itself, as in Elm.
172
+
173
+ Args:
174
+ a: The first value.
175
+ b: The second value.
176
+
177
+ Returns:
178
+ True when the two values are structurally equal.
179
+
180
+ Raises:
181
+ TypeError: If a value is a function, or holds a function anywhere
182
+ inside a tuple, a list or an SDK data type such as `Just`, `Ok` or
183
+ `Dict`. Elm's runtime also fails on these values.
184
+ """
185
+ if _contains_function(a) or _contains_function(b):
186
+ raise TypeError("equal: cannot compare functions")
187
+ return bool(a == b)
@@ -0,0 +1,421 @@
1
+ """Morphir.SDK.Aggregate: aggregations for large data sets.
2
+
3
+ An `Aggregation[A, K]` tells how to aggregate rows of type `A`: the key that
4
+ groups the rows, a filter that removes rows before the aggregation, and the
5
+ operator (count, sum, average, minimum, maximum or weighted average). Build one
6
+ with `count`, `sum_of`, `average_of`, `minimum_of`, `maximum_of` or
7
+ `weighted_average_of`, then set the key with `by_key` and the filter with
8
+ `with_filter`. All aggregated values are floats.
9
+
10
+ There are two ways to use an aggregation:
11
+
12
+ * `aggregate_map` to `aggregate_map4` map each row together with the aggregated
13
+ values of the group of that row.
14
+ * `group_by` and then `aggregate` yield one item for each group.
15
+
16
+ >>> from morphir.sdk import aggregate as Aggregate
17
+ >>> rows = (("a", 1.0), ("a", 2.0), ("b", 5.0))
18
+ >>> total = Aggregate.by_key(lambda r: r[0], Aggregate.sum_of(lambda r: r[1]))
19
+ >>> Aggregate.aggregate_map(total, lambda t, r: (r[0], r[1] / t), rows)
20
+ (('a', 0.3333333333333333), ('a', 0.6666666666666666), ('b', 1.0))
21
+ >>> Aggregate.aggregate(
22
+ ... lambda key, inputs: (key, inputs(Aggregate.count())),
23
+ ... Aggregate.group_by(lambda r: r[0], rows),
24
+ ... )
25
+ (('a', 2.0), ('b', 1.0))
26
+
27
+ All functions take their arguments in Elm order, with the data last, and are
28
+ not curried. Lists are tuples: the functions accept any `Sequence` and yield a
29
+ tuple.
30
+
31
+ This module is the runtime API only. The Elm module also has
32
+ `constructAggregationCall`, `AggregationCall`, `AggregateValue` and
33
+ `ConstructAggregationError`. Those inspect Morphir IR values (`TypedValue`) for
34
+ the backends. They are tooling, they are not in the `Morphir.IR.SDK.Aggregate`
35
+ specification, and they are not part of this module.
36
+ """
37
+
38
+ from dataclasses import dataclass
39
+ from typing import TYPE_CHECKING
40
+
41
+ from morphir.sdk import _compare, basics
42
+ from morphir.sdk import dict as dict_
43
+ from morphir.sdk.key import Key0, key0
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Callable, Sequence
47
+
48
+ from morphir.sdk._compare import Comparable
49
+ from morphir.sdk.dict import Dict
50
+
51
+ __all__ = [
52
+ "Aggregation",
53
+ "Aggregator",
54
+ "Avg",
55
+ "Count",
56
+ "Max",
57
+ "Min",
58
+ "Operator",
59
+ "Sum",
60
+ "WAvg",
61
+ "aggregate",
62
+ "aggregate_map",
63
+ "aggregate_map2",
64
+ "aggregate_map3",
65
+ "aggregate_map4",
66
+ "average_of",
67
+ "by_key",
68
+ "count",
69
+ "group_by",
70
+ "maximum_of",
71
+ "minimum_of",
72
+ "sum_of",
73
+ "weighted_average_of",
74
+ "with_filter",
75
+ ]
76
+
77
+
78
+ # Operators
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class Count:
83
+ """The operator that counts the rows."""
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class Sum[A]:
88
+ """The operator that adds the values of the rows."""
89
+
90
+ get_value: Callable[[A], float]
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class Avg[A]:
95
+ """The operator that gives the average of the values of the rows."""
96
+
97
+ get_value: Callable[[A], float]
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class Min[A]:
102
+ """The operator that gives the smallest of the values of the rows."""
103
+
104
+ get_value: Callable[[A], float]
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class Max[A]:
109
+ """The operator that gives the largest of the values of the rows."""
110
+
111
+ get_value: Callable[[A], float]
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class WAvg[A]:
116
+ """The operator that gives the weighted average of the values of the rows."""
117
+
118
+ get_weight: Callable[[A], float]
119
+ get_value: Callable[[A], float]
120
+
121
+
122
+ type Operator[A] = Count | Sum[A] | Avg[A] | Min[A] | Max[A] | WAvg[A]
123
+ """The aggregation operation to apply to the rows of a group."""
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class Aggregation[A, K]:
128
+ """An aggregation on rows of type `A` with a key of type `K`.
129
+
130
+ Attributes:
131
+ key: A function that gives the key of a row. Rows with equal keys are
132
+ aggregated together.
133
+ filter: A test that a row must pass to be part of the aggregation.
134
+ operator: The aggregation operation to apply.
135
+ """
136
+
137
+ key: Callable[[A], K]
138
+ filter: Callable[[A], bool]
139
+ operator: Operator[A]
140
+
141
+
142
+ type Aggregator[A, K] = Callable[[Aggregation[A, K]], float]
143
+ """A function that applies an aggregation to the rows of one group."""
144
+
145
+
146
+ def _always_true(_: object) -> bool:
147
+ return True
148
+
149
+
150
+ def _one(_: object) -> float:
151
+ return 1.0
152
+
153
+
154
+ def _operator_to_aggregation[A](operator: Operator[A]) -> Aggregation[A, Key0]:
155
+ return Aggregation(key=key0, filter=_always_true, operator=operator)
156
+
157
+
158
+ def count[A]() -> Aggregation[A, Key0]: # pyright: ignore[reportInvalidTypeVarUse]
159
+ """Count the rows in a group.
160
+
161
+ In Elm `count` is a value. Here it is a function with no arguments, so that
162
+ the type checker can give each use its own row type.
163
+ """
164
+ return _operator_to_aggregation(Count())
165
+
166
+
167
+ def sum_of[A](get_value: Callable[[A], float]) -> Aggregation[A, Key0]:
168
+ """Add the values that a function gives for each row of a group."""
169
+ return _operator_to_aggregation(Sum(get_value))
170
+
171
+
172
+ def average_of[A](get_value: Callable[[A], float]) -> Aggregation[A, Key0]:
173
+ """Give the average of the values that a function gives for each row."""
174
+ return _operator_to_aggregation(Avg(get_value))
175
+
176
+
177
+ def minimum_of[A](get_value: Callable[[A], float]) -> Aggregation[A, Key0]:
178
+ """Give the smallest of the values that a function gives for each row."""
179
+ return _operator_to_aggregation(Min(get_value))
180
+
181
+
182
+ def maximum_of[A](get_value: Callable[[A], float]) -> Aggregation[A, Key0]:
183
+ """Give the largest of the values that a function gives for each row."""
184
+ return _operator_to_aggregation(Max(get_value))
185
+
186
+
187
+ def weighted_average_of[A](
188
+ get_weight: Callable[[A], float], get_value: Callable[[A], float]
189
+ ) -> Aggregation[A, Key0]:
190
+ """Give the weighted average of the values of the rows of a group.
191
+
192
+ Args:
193
+ get_weight: A function that gives the weight of a row.
194
+ get_value: A function that gives the value of a row.
195
+
196
+ Returns:
197
+ An aggregation that divides the sum of `weight * value` by the sum of
198
+ the weights. When the sum of the weights is zero the division follows
199
+ IEEE 754, as in Elm: the result is `NaN` or an infinity.
200
+ """
201
+ return _operator_to_aggregation(WAvg(get_weight, get_value))
202
+
203
+
204
+ def by_key[A, K](
205
+ key: Callable[[A], K], aggregation: Aggregation[A, Key0]
206
+ ) -> Aggregation[A, K]:
207
+ """Set the key of an aggregation. The filter and the operator stay."""
208
+ return Aggregation(
209
+ key=key, filter=aggregation.filter, operator=aggregation.operator
210
+ )
211
+
212
+
213
+ def with_filter[A, K](
214
+ filter: Callable[[A], bool],
215
+ aggregation: Aggregation[A, K],
216
+ ) -> Aggregation[A, K]:
217
+ """Set the filter of an aggregation. The key and the operator stay.
218
+
219
+ A filter that the aggregation already has is replaced, as in Elm.
220
+ """
221
+ return Aggregation(
222
+ key=aggregation.key, filter=filter, operator=aggregation.operator
223
+ )
224
+
225
+
226
+ # Aggregation of rows
227
+
228
+
229
+ def _elm_min(a: float, b: float) -> float:
230
+ return a if a < b else b
231
+
232
+
233
+ def _elm_max(a: float, b: float) -> float:
234
+ return a if a > b else b
235
+
236
+
237
+ def _fold[A](
238
+ get_key: Callable[[A], object],
239
+ get_value: Callable[[A], float],
240
+ combine: Callable[[float, float], float],
241
+ rows: Sequence[A],
242
+ ) -> dict[object, float]:
243
+ totals: dict[object, float] = {}
244
+ for row in rows:
245
+ key = _compare.freeze(get_key(row))
246
+ value = get_value(row)
247
+ totals[key] = combine(totals[key], value) if key in totals else value
248
+ return totals
249
+
250
+
251
+ def _add(a: float, b: float) -> float:
252
+ return a + b
253
+
254
+
255
+ def _ratio(
256
+ numerators: dict[object, float], denominators: dict[object, float]
257
+ ) -> dict[object, float]:
258
+ return {
259
+ key: basics.divide(numerator, denominators.get(key, 0.0))
260
+ for key, numerator in numerators.items()
261
+ }
262
+
263
+
264
+ def _aggregate_help[A](
265
+ get_key: Callable[[A], object], operator: Operator[A], rows: Sequence[A]
266
+ ) -> dict[object, float]:
267
+ match operator:
268
+ case Count():
269
+ return _fold(get_key, _one, _add, rows)
270
+ case Sum(get_value):
271
+ return _fold(get_key, get_value, _add, rows)
272
+ case Avg(get_value):
273
+ return _ratio(
274
+ _fold(get_key, get_value, _add, rows),
275
+ _fold(get_key, _one, _add, rows),
276
+ )
277
+ case Min(get_value):
278
+ return _fold(get_key, get_value, _elm_min, rows)
279
+ case Max(get_value):
280
+ return _fold(get_key, get_value, _elm_max, rows)
281
+ case WAvg(get_weight, get_value):
282
+
283
+ def weighted(row: A) -> float:
284
+ return get_weight(row) * get_value(row)
285
+
286
+ return _ratio(
287
+ _fold(get_key, weighted, _add, rows),
288
+ _fold(get_key, get_weight, _add, rows),
289
+ )
290
+
291
+
292
+ def _lookup[A, K](
293
+ aggregation: Aggregation[A, K], rows: Sequence[A]
294
+ ) -> Callable[[A], float]:
295
+ included = tuple(row for row in rows if aggregation.filter(row))
296
+ totals = _aggregate_help(aggregation.key, aggregation.operator, included)
297
+
298
+ def value_for(row: A) -> float:
299
+ return totals.get(_compare.freeze(aggregation.key(row)), 0.0)
300
+
301
+ return value_for
302
+
303
+
304
+ def aggregate_map[A, B, K1](
305
+ agg1: Aggregation[A, K1], f: Callable[[float, A], B], xs: Sequence[A]
306
+ ) -> tuple[B, ...]:
307
+ """Map each row together with one aggregated value.
308
+
309
+ The aggregation is computed one time on the rows that pass its filter. Each
310
+ row of the list, also one that does not pass the filter, then gets the
311
+ aggregated value for its key.
312
+
313
+ Args:
314
+ agg1: The aggregation.
315
+ f: The mapping function. It gets the aggregated value for the key of
316
+ the row, then the row. The value is 0 when no row with that key
317
+ passed the filter.
318
+ xs: The rows.
319
+
320
+ Returns:
321
+ The mapped rows, in the order of the input.
322
+ """
323
+ value1 = _lookup(agg1, xs)
324
+ return tuple(f(value1(x), x) for x in xs)
325
+
326
+
327
+ def aggregate_map2[A, B, K1, K2](
328
+ agg1: Aggregation[A, K1],
329
+ agg2: Aggregation[A, K2],
330
+ f: Callable[[float, float, A], B],
331
+ xs: Sequence[A],
332
+ ) -> tuple[B, ...]:
333
+ """Map each row together with two aggregated values. See `aggregate_map`."""
334
+ value1 = _lookup(agg1, xs)
335
+ value2 = _lookup(agg2, xs)
336
+ return tuple(f(value1(x), value2(x), x) for x in xs)
337
+
338
+
339
+ def aggregate_map3[A, B, K1, K2, K3](
340
+ agg1: Aggregation[A, K1],
341
+ agg2: Aggregation[A, K2],
342
+ agg3: Aggregation[A, K3],
343
+ f: Callable[[float, float, float, A], B],
344
+ xs: Sequence[A],
345
+ ) -> tuple[B, ...]:
346
+ """Map each row together with three aggregated values. See `aggregate_map`."""
347
+ value1 = _lookup(agg1, xs)
348
+ value2 = _lookup(agg2, xs)
349
+ value3 = _lookup(agg3, xs)
350
+ return tuple(f(value1(x), value2(x), value3(x), x) for x in xs)
351
+
352
+
353
+ def aggregate_map4[A, B, K1, K2, K3, K4](
354
+ agg1: Aggregation[A, K1],
355
+ agg2: Aggregation[A, K2],
356
+ agg3: Aggregation[A, K3],
357
+ agg4: Aggregation[A, K4],
358
+ f: Callable[[float, float, float, float, A], B],
359
+ xs: Sequence[A],
360
+ ) -> tuple[B, ...]:
361
+ """Map each row together with four aggregated values. See `aggregate_map`."""
362
+ value1 = _lookup(agg1, xs)
363
+ value2 = _lookup(agg2, xs)
364
+ value3 = _lookup(agg3, xs)
365
+ value4 = _lookup(agg4, xs)
366
+ return tuple(f(value1(x), value2(x), value3(x), value4(x), x) for x in xs)
367
+
368
+
369
+ # Grouping
370
+
371
+
372
+ def group_by[A, K: Comparable](
373
+ get_key: Callable[[A], K], xs: Sequence[A]
374
+ ) -> Dict[K, tuple[A, ...]]:
375
+ """Group the rows of a list into a dictionary.
376
+
377
+ Args:
378
+ get_key: A function that gives the key of a row.
379
+ xs: The rows.
380
+
381
+ Returns:
382
+ A dictionary from each key to the rows with that key. The rows of a
383
+ group keep the order that they have in the list.
384
+
385
+ Raises:
386
+ TypeError: If the keys are not comparable.
387
+ """
388
+ groups: dict[K, list[A]] = {}
389
+ for x in xs:
390
+ groups.setdefault(_compare.freeze(get_key(x)), []).append(x)
391
+ return dict_.from_list([(key, tuple(rows)) for key, rows in groups.items()])
392
+
393
+
394
+ def aggregate[A, B, K: Comparable](
395
+ f: Callable[[K, Aggregator[A, Key0]], B], groups: Dict[K, tuple[A, ...]]
396
+ ) -> tuple[B, ...]:
397
+ """Aggregate a dictionary of groups into a list with one item for each key.
398
+
399
+ This function is for use after `group_by`.
400
+
401
+ Args:
402
+ f: A function that gets a key and an aggregator, and yields the item for
403
+ that key. The aggregator is a function that takes an aggregation
404
+ with no key (`count()`, `sum_of(...)`, also with `with_filter`) and
405
+ yields the aggregated value for the rows of the group. The value is
406
+ 0 when no row passes the filter.
407
+ groups: The dictionary of groups.
408
+
409
+ Returns:
410
+ One item for each key, in key order.
411
+ """
412
+
413
+ def item(key: K, rows: tuple[A, ...]) -> B:
414
+ def aggregator(aggregation: Aggregation[A, Key0]) -> float:
415
+ included = tuple(row for row in rows if aggregation.filter(row))
416
+ totals = _aggregate_help(aggregation.key, aggregation.operator, included)
417
+ return totals.get(0, 0.0)
418
+
419
+ return f(key, aggregator)
420
+
421
+ return tuple(item(key, rows) for key, rows in dict_.to_list(groups))