calc-flow-python 4.0.0__cp313-abi3-win_amd64.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.
- calc_flow/__init__.py +169 -0
- calc_flow/_native.pyd +0 -0
- calc_flow/_native.pyi +366 -0
- calc_flow/array.py +1324 -0
- calc_flow/capabilities.py +775 -0
- calc_flow/config.py +219 -0
- calc_flow/errors.py +25 -0
- calc_flow/join_spec.py +156 -0
- calc_flow/pipeline.py +1123 -0
- calc_flow/py.typed +0 -0
- calc_flow/runtime.py +979 -0
- calc_flow/store.py +138 -0
- calc_flow/symbolic/__init__.py +65 -0
- calc_flow/symbolic/_generated_rolling_kernels.py +23 -0
- calc_flow/symbolic/analyzer.py +2280 -0
- calc_flow/symbolic/domains.py +77 -0
- calc_flow/symbolic/errors.py +58 -0
- calc_flow/symbolic/expr.py +662 -0
- calc_flow/symbolic/lower/__init__.py +31 -0
- calc_flow/symbolic/lower/planners.py +1270 -0
- calc_flow/symbolic/lower/program.py +840 -0
- calc_flow/symbolic/lower/segments.py +836 -0
- calc_flow/symbolic/lower/strategies.py +1472 -0
- calc_flow/symbolic/nodes.py +603 -0
- calc_flow/symbolic/ops.py +1155 -0
- calc_flow/symbolic/optimizer.py +600 -0
- calc_flow/symbolic/program.py +377 -0
- calc_flow/symbolic/types.py +110 -0
- calc_flow/symbolic/windows.py +153 -0
- calc_flow/udf.py +19 -0
- calc_flow_python-4.0.0.dist-info/METADATA +376 -0
- calc_flow_python-4.0.0.dist-info/RECORD +35 -0
- calc_flow_python-4.0.0.dist-info/WHEEL +4 -0
- calc_flow_python-4.0.0.dist-info/licenses/LICENSE +202 -0
- calc_flow_python-4.0.0.dist-info/sboms/calc-flow-python.cyclonedx.json +10081 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Immutable feature sets and programs over symbolic declarations.
|
|
2
|
+
|
|
3
|
+
``FeatureSet`` and ``Program`` are the program-level declaration values of the
|
|
4
|
+
frozen public surface. A program owns its declared inputs and outputs, the
|
|
5
|
+
``calc_flow.symbolic.declaration.v1`` program fingerprint, and the declaration
|
|
6
|
+
processing entry points ``analyze``/``explain``. Compilation to execution plans
|
|
7
|
+
is a later lowering stage and is deliberately absent here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from calc_flow.symbolic.domains import type_name
|
|
18
|
+
from calc_flow.symbolic.expr import ArrayExpr, ColumnExpr, Parameter, TableExpr
|
|
19
|
+
from calc_flow.symbolic.nodes import (
|
|
20
|
+
_MAGIC,
|
|
21
|
+
CStr,
|
|
22
|
+
Node,
|
|
23
|
+
_text,
|
|
24
|
+
_u64,
|
|
25
|
+
)
|
|
26
|
+
from calc_flow.symbolic.types import CompileMode
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from calc_flow.pipeline import BatchExecutionPlan, Runtime, StreamExecutionPlan
|
|
30
|
+
from calc_flow.symbolic.analyzer import AnalysisResult
|
|
31
|
+
from calc_flow.symbolic.types import LatePolicy
|
|
32
|
+
|
|
33
|
+
_PROGRAM_TAG = 0x21
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True, eq=False, init=False)
|
|
37
|
+
class FeatureSet:
|
|
38
|
+
"""An ordered immutable set of uniquely named column expressions."""
|
|
39
|
+
|
|
40
|
+
_features: tuple[tuple[str, ColumnExpr], ...]
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
features: Sequence[tuple[str, ColumnExpr]] = (),
|
|
45
|
+
/,
|
|
46
|
+
) -> None:
|
|
47
|
+
copied: list[tuple[str, ColumnExpr]] = []
|
|
48
|
+
seen: set[str] = set()
|
|
49
|
+
for index, item in enumerate(features):
|
|
50
|
+
if not isinstance(item, tuple) or len(item) != 2:
|
|
51
|
+
raise TypeError(
|
|
52
|
+
f"features[{index}]: must be a (name, ColumnExpr) pair; got"
|
|
53
|
+
f" {type_name(item)}"
|
|
54
|
+
)
|
|
55
|
+
name, value = item
|
|
56
|
+
if type(name) is not str:
|
|
57
|
+
raise TypeError(
|
|
58
|
+
f"features[{index}].name: must be a string; got {type_name(name)}"
|
|
59
|
+
)
|
|
60
|
+
if not isinstance(value, ColumnExpr):
|
|
61
|
+
raise TypeError(
|
|
62
|
+
f"features[{index}].value: must be a ColumnExpr; got"
|
|
63
|
+
f" {type_name(value)}"
|
|
64
|
+
)
|
|
65
|
+
if name in seen:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"features[{index}].name: duplicate_name: duplicate feature"
|
|
68
|
+
f" name {name!r}"
|
|
69
|
+
)
|
|
70
|
+
seen.add(name)
|
|
71
|
+
copied.append((name, value))
|
|
72
|
+
object.__setattr__(self, "_features", tuple(copied))
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def features(self) -> tuple[tuple[str, ColumnExpr], ...]:
|
|
76
|
+
"""The declared features in declaration order."""
|
|
77
|
+
|
|
78
|
+
return self._features
|
|
79
|
+
|
|
80
|
+
def with_feature(self, name: str, value: ColumnExpr, /) -> FeatureSet:
|
|
81
|
+
"""Return a new feature set with one feature appended."""
|
|
82
|
+
|
|
83
|
+
return FeatureSet((*self._features, (name, value)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _node_name(node: Node, /) -> str:
|
|
87
|
+
value = node.attr("name")
|
|
88
|
+
if isinstance(value, CStr):
|
|
89
|
+
return value.value
|
|
90
|
+
raise TypeError("declaration node is missing its name attribute")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _collect_program_nodes(
|
|
94
|
+
inputs: tuple[TableExpr | Parameter[object], ...],
|
|
95
|
+
outputs: tuple[tuple[str, TableExpr | ArrayExpr], ...],
|
|
96
|
+
/,
|
|
97
|
+
) -> dict[str, tuple[bytes, Node]]:
|
|
98
|
+
"""Return every unique node reachable from a declared input or output."""
|
|
99
|
+
|
|
100
|
+
nodes: dict[str, tuple[bytes, Node]] = {}
|
|
101
|
+
|
|
102
|
+
def visit(node: Node) -> None:
|
|
103
|
+
existing = nodes.get(node.digest)
|
|
104
|
+
if existing is not None:
|
|
105
|
+
if existing[0] != node.node_bytes:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"Program.fingerprint: unresolved_type: digest collision for"
|
|
108
|
+
f" node {node.op.name}@{node.op.version}"
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
nodes[node.digest] = (node.node_bytes, node)
|
|
112
|
+
for child in node.args:
|
|
113
|
+
visit(child)
|
|
114
|
+
|
|
115
|
+
for value in inputs:
|
|
116
|
+
visit(value._node)
|
|
117
|
+
for _, value in outputs:
|
|
118
|
+
visit(value._node)
|
|
119
|
+
return nodes
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _program_fingerprint(
|
|
123
|
+
name: str,
|
|
124
|
+
inputs: tuple[TableExpr | Parameter[object], ...],
|
|
125
|
+
outputs: tuple[tuple[str, TableExpr | ArrayExpr], ...],
|
|
126
|
+
/,
|
|
127
|
+
) -> str:
|
|
128
|
+
"""Compute the frozen v1 program fingerprint over the declaration graph."""
|
|
129
|
+
|
|
130
|
+
nodes = _collect_program_nodes(inputs, outputs)
|
|
131
|
+
edges = _program_edges(nodes)
|
|
132
|
+
node_records = sorted(
|
|
133
|
+
((bytes.fromhex(digest), record) for digest, record in nodes.items()),
|
|
134
|
+
key=lambda item: item[0],
|
|
135
|
+
)
|
|
136
|
+
body = (
|
|
137
|
+
bytes((_PROGRAM_TAG,))
|
|
138
|
+
+ _text(name)
|
|
139
|
+
+ _u64(len(inputs))
|
|
140
|
+
+ b"".join(_input_records(inputs))
|
|
141
|
+
+ _u64(len(outputs))
|
|
142
|
+
+ b"".join(
|
|
143
|
+
_text(output_name) + bytes.fromhex(value._node.digest)
|
|
144
|
+
for output_name, value in outputs
|
|
145
|
+
)
|
|
146
|
+
+ _u64(len(node_records))
|
|
147
|
+
+ b"".join(
|
|
148
|
+
digest + _u64(len(record[0])) + record[0] for digest, record in node_records
|
|
149
|
+
)
|
|
150
|
+
+ _u64(len(edges))
|
|
151
|
+
+ b"".join(parent + _u64(index) + child for parent, index, child in edges)
|
|
152
|
+
)
|
|
153
|
+
return hashlib.sha256(_MAGIC + b"\x02" + _u64(len(body)) + body).hexdigest()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _program_edges(
|
|
157
|
+
nodes: dict[str, tuple[bytes, Node]],
|
|
158
|
+
/,
|
|
159
|
+
) -> list[tuple[bytes, int, bytes]]:
|
|
160
|
+
edges: list[tuple[bytes, int, bytes]] = []
|
|
161
|
+
for _, node in nodes.values():
|
|
162
|
+
for index, child in enumerate(node.args):
|
|
163
|
+
edges.append(
|
|
164
|
+
(
|
|
165
|
+
bytes.fromhex(node.digest),
|
|
166
|
+
index,
|
|
167
|
+
bytes.fromhex(child.digest),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
edges.sort()
|
|
171
|
+
return edges
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _input_records(
|
|
175
|
+
inputs: tuple[TableExpr | Parameter[object], ...],
|
|
176
|
+
/,
|
|
177
|
+
) -> list[bytes]:
|
|
178
|
+
return [
|
|
179
|
+
_text(_node_name(value._node)) + bytes.fromhex(value._node.digest)
|
|
180
|
+
for value in inputs
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _validated_inputs(
|
|
185
|
+
inputs: Sequence[TableExpr | Parameter[object]], /
|
|
186
|
+
) -> tuple[TableExpr | Parameter[object], ...]:
|
|
187
|
+
declared: dict[str, str] = {}
|
|
188
|
+
copied: list[TableExpr | Parameter[object]] = []
|
|
189
|
+
for index, value in enumerate(inputs):
|
|
190
|
+
if not isinstance(value, (TableExpr, Parameter)):
|
|
191
|
+
raise TypeError(
|
|
192
|
+
f"Program.inputs[{index}]: expected TableExpr |"
|
|
193
|
+
f" Parameter[object]; got {type_name(value)}"
|
|
194
|
+
)
|
|
195
|
+
if value._node.op.name not in ("table_input", "parameter"):
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"Program.inputs[{index}]: invalid_literal: program inputs"
|
|
198
|
+
" must be declared table_input or parameter values; got"
|
|
199
|
+
f" {value._node.op.name}"
|
|
200
|
+
)
|
|
201
|
+
input_name = _node_name(value._node)
|
|
202
|
+
root = "static_inputs" if isinstance(value, Parameter) else "inputs"
|
|
203
|
+
if input_name in declared:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"{root}.{input_name}: duplicate_name: duplicate input name"
|
|
206
|
+
f" {input_name!r}"
|
|
207
|
+
)
|
|
208
|
+
declared[input_name] = root
|
|
209
|
+
copied.append(value)
|
|
210
|
+
return tuple(copied)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _validated_output(index: int, item: object, /) -> tuple[str, TableExpr | ArrayExpr]:
|
|
214
|
+
if not isinstance(item, tuple) or len(item) != 2:
|
|
215
|
+
raise TypeError(
|
|
216
|
+
f"Program.outputs[{index}]: must be a (name, TableExpr |"
|
|
217
|
+
f" ArrayExpr) pair; got {type_name(item)}"
|
|
218
|
+
)
|
|
219
|
+
output_name, value = item
|
|
220
|
+
if type(output_name) is not str:
|
|
221
|
+
raise TypeError(
|
|
222
|
+
f"Program.outputs[{index}].name: must be a string; got"
|
|
223
|
+
f" {type_name(output_name)}"
|
|
224
|
+
)
|
|
225
|
+
if not output_name:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"Program.outputs[{index}].name: invalid_literal: must be a"
|
|
228
|
+
" non-empty string"
|
|
229
|
+
)
|
|
230
|
+
if not isinstance(value, (TableExpr, ArrayExpr)):
|
|
231
|
+
raise TypeError(
|
|
232
|
+
f"Program.outputs[{index}].value: expected TableExpr |"
|
|
233
|
+
f" ArrayExpr; got {type_name(value)}"
|
|
234
|
+
)
|
|
235
|
+
return output_name, value
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _validated_outputs(
|
|
239
|
+
outputs: Sequence[tuple[str, TableExpr | ArrayExpr]], /
|
|
240
|
+
) -> tuple[tuple[str, TableExpr | ArrayExpr], ...]:
|
|
241
|
+
copied: list[tuple[str, TableExpr | ArrayExpr]] = []
|
|
242
|
+
names: set[str] = set()
|
|
243
|
+
for index, item in enumerate(outputs):
|
|
244
|
+
output_name, value = _validated_output(index, item)
|
|
245
|
+
if output_name in names:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"outputs.{output_name}: duplicate_name: duplicate output"
|
|
248
|
+
f" name {output_name!r}"
|
|
249
|
+
)
|
|
250
|
+
names.add(output_name)
|
|
251
|
+
copied.append((output_name, value))
|
|
252
|
+
return tuple(copied)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True, slots=True, eq=False, init=False)
|
|
256
|
+
class Program:
|
|
257
|
+
"""An immutable program of declared inputs, outputs, and expressions."""
|
|
258
|
+
|
|
259
|
+
_name: str
|
|
260
|
+
_inputs: tuple[TableExpr | Parameter[object], ...]
|
|
261
|
+
_outputs: tuple[tuple[str, TableExpr | ArrayExpr], ...]
|
|
262
|
+
_fingerprint: str
|
|
263
|
+
|
|
264
|
+
def __init__(
|
|
265
|
+
self,
|
|
266
|
+
name: str,
|
|
267
|
+
/,
|
|
268
|
+
*,
|
|
269
|
+
inputs: Sequence[TableExpr | Parameter[object]] = (),
|
|
270
|
+
outputs: Sequence[tuple[str, TableExpr | ArrayExpr]] = (),
|
|
271
|
+
) -> None:
|
|
272
|
+
if type(name) is not str:
|
|
273
|
+
raise TypeError(f"Program.name must be a string; got {type_name(name)}")
|
|
274
|
+
if not name:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
"Program.name: invalid_literal: must be a non-empty string"
|
|
277
|
+
)
|
|
278
|
+
copied_inputs = _validated_inputs(inputs)
|
|
279
|
+
copied_outputs = _validated_outputs(outputs)
|
|
280
|
+
object.__setattr__(self, "_name", name)
|
|
281
|
+
object.__setattr__(self, "_inputs", copied_inputs)
|
|
282
|
+
object.__setattr__(self, "_outputs", copied_outputs)
|
|
283
|
+
object.__setattr__(
|
|
284
|
+
self,
|
|
285
|
+
"_fingerprint",
|
|
286
|
+
_program_fingerprint(name, copied_inputs, copied_outputs),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def name(self) -> str:
|
|
291
|
+
"""The declared program name."""
|
|
292
|
+
|
|
293
|
+
return self._name
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def inputs(self) -> tuple[TableExpr | Parameter[object], ...]:
|
|
297
|
+
"""The declared inputs in declaration order."""
|
|
298
|
+
|
|
299
|
+
return self._inputs
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def outputs(self) -> tuple[tuple[str, TableExpr | ArrayExpr], ...]:
|
|
303
|
+
"""The declared outputs in declaration order."""
|
|
304
|
+
|
|
305
|
+
return self._outputs
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def fingerprint(self) -> str:
|
|
309
|
+
"""The runtime-independent v1 program fingerprint."""
|
|
310
|
+
|
|
311
|
+
return self._fingerprint
|
|
312
|
+
|
|
313
|
+
def with_input(self, value: TableExpr | Parameter[object], /) -> Program:
|
|
314
|
+
"""Return a new program with one declared input appended."""
|
|
315
|
+
|
|
316
|
+
return Program(
|
|
317
|
+
self._name,
|
|
318
|
+
inputs=(*self._inputs, value),
|
|
319
|
+
outputs=self._outputs,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def output(self, name: str, value: TableExpr | ArrayExpr, /) -> Program:
|
|
323
|
+
"""Return a new program with one declared output appended."""
|
|
324
|
+
|
|
325
|
+
return Program(
|
|
326
|
+
self._name,
|
|
327
|
+
inputs=self._inputs,
|
|
328
|
+
outputs=(*self._outputs, (name, value)),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def analyze(self, runtime: Runtime, /, *, mode: CompileMode) -> AnalysisResult:
|
|
332
|
+
"""Analyze this program against one immutable capability snapshot."""
|
|
333
|
+
|
|
334
|
+
from calc_flow.symbolic.analyzer import analyze_program
|
|
335
|
+
|
|
336
|
+
return analyze_program(self, runtime, mode)
|
|
337
|
+
|
|
338
|
+
def explain(self, runtime: Runtime, /, *, mode: CompileMode) -> str:
|
|
339
|
+
"""Render deterministic analysis facts for this program."""
|
|
340
|
+
|
|
341
|
+
from calc_flow.symbolic.analyzer import explain_program
|
|
342
|
+
|
|
343
|
+
return explain_program(self, runtime, mode)
|
|
344
|
+
|
|
345
|
+
def compile_batch(self, runtime: Runtime, /) -> BatchExecutionPlan:
|
|
346
|
+
"""Lower this program to a strict project-v3 batch execution plan.
|
|
347
|
+
|
|
348
|
+
Compilation is declaration processing only: it captures one immutable
|
|
349
|
+
capability snapshot, lowers one strict project-v3 document, and invokes
|
|
350
|
+
the Rust graph compiler for final validation. No data, source, sink,
|
|
351
|
+
or runner is accepted.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
from calc_flow.symbolic.lower import compile_program_batch
|
|
355
|
+
|
|
356
|
+
return compile_program_batch(self, runtime)
|
|
357
|
+
|
|
358
|
+
def compile_stream(
|
|
359
|
+
self,
|
|
360
|
+
runtime: Runtime,
|
|
361
|
+
/,
|
|
362
|
+
*,
|
|
363
|
+
allowed_lateness_micros: int = 0,
|
|
364
|
+
late_policy: LatePolicy = "error",
|
|
365
|
+
) -> StreamExecutionPlan:
|
|
366
|
+
"""Lower this program to a strict project-v3 continuous plan.
|
|
367
|
+
|
|
368
|
+
The lateness arguments are validated and serialized into every lowered
|
|
369
|
+
rolling or cross-section stage. Row-local-only programs accept the same
|
|
370
|
+
compile signature but have no stateful late-row surface.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
from calc_flow.symbolic.lower import compile_program_stream
|
|
374
|
+
|
|
375
|
+
return compile_program_stream(
|
|
376
|
+
self, runtime, allowed_lateness_micros, late_policy
|
|
377
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Strict declaration types and validators for symbolic expressions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from calc_flow.capabilities import PORTABLE_ARROW_TYPES
|
|
10
|
+
from calc_flow.symbolic.domains import type_name
|
|
11
|
+
|
|
12
|
+
type BatchKind = Literal["table", "array"]
|
|
13
|
+
type CompileMode = Literal["batch", "stream"]
|
|
14
|
+
type LatePolicy = Literal["error", "drop"]
|
|
15
|
+
type ScalarLiteral = None | bool | int | float | str
|
|
16
|
+
|
|
17
|
+
# The initial portable field spelling adds ``timestamp[us, UTC]`` to the
|
|
18
|
+
# current portable Arrow names (API note section 2.2).
|
|
19
|
+
TABLE_FIELD_TYPES = frozenset((*PORTABLE_ARROW_TYPES, "timestamp[us, UTC]"))
|
|
20
|
+
# Array declaration dtypes stay inside the static-input digest v1 coverage:
|
|
21
|
+
# bool, the integer types, float32, and float64.
|
|
22
|
+
ARRAY_DTYPES = frozenset(
|
|
23
|
+
(
|
|
24
|
+
"bool",
|
|
25
|
+
"float32",
|
|
26
|
+
"float64",
|
|
27
|
+
"int16",
|
|
28
|
+
"int32",
|
|
29
|
+
"int64",
|
|
30
|
+
"int8",
|
|
31
|
+
"uint16",
|
|
32
|
+
"uint32",
|
|
33
|
+
"uint64",
|
|
34
|
+
"uint8",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class Field:
|
|
41
|
+
"""One exact table field declaration with host-type validation."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
data_type: str
|
|
45
|
+
nullable: bool = True
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
if type(self.name) is not str:
|
|
49
|
+
raise TypeError(f"Field.name must be a string; got {type_name(self.name)}")
|
|
50
|
+
if type(self.data_type) is not str:
|
|
51
|
+
raise TypeError(
|
|
52
|
+
f"Field.data_type must be a string; got {type_name(self.data_type)}"
|
|
53
|
+
)
|
|
54
|
+
if type(self.nullable) is not bool:
|
|
55
|
+
raise TypeError(
|
|
56
|
+
f"Field.nullable must be a boolean; got {type_name(self.nullable)}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def require_str(value: object, path: str, /) -> str:
|
|
61
|
+
if type(value) is not str:
|
|
62
|
+
raise TypeError(f"{path} must be a string; got {type_name(value)}")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def require_non_empty_str(value: object, path: str, /) -> str:
|
|
67
|
+
require_str(value, path)
|
|
68
|
+
if not value: # type: ignore[arg-type]
|
|
69
|
+
raise ValueError(f"{path}: invalid_literal: must be a non-empty string")
|
|
70
|
+
return value # type: ignore[return-value]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def require_int(value: object, path: str, /) -> int:
|
|
74
|
+
if type(value) is not int:
|
|
75
|
+
raise TypeError(f"{path} must be an integer; got {type_name(value)}")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def require_positive_int(value: object, path: str, /) -> int:
|
|
80
|
+
require_int(value, path)
|
|
81
|
+
if value <= 0:
|
|
82
|
+
raise ValueError(f"{path}: invalid_literal: must be a positive integer")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def require_non_negative_int(value: object, path: str, /) -> int:
|
|
87
|
+
require_int(value, path)
|
|
88
|
+
if value < 0:
|
|
89
|
+
raise ValueError(f"{path}: invalid_literal: must be a non-negative integer")
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def require_finite_number(value: object, path: str, /) -> int | float:
|
|
94
|
+
if type(value) not in (int, float):
|
|
95
|
+
raise TypeError(f"{path} must be a finite number; got {type_name(value)}")
|
|
96
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
97
|
+
raise ValueError(f"{path}: invalid_literal: must be finite")
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_table_field_type(data_type: str, path: str, /) -> None:
|
|
102
|
+
if data_type not in TABLE_FIELD_TYPES:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"{path}: unsupported_type: unknown portable Arrow type {data_type!r}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_array_dtype(dtype: str, path: str, /) -> None:
|
|
109
|
+
if dtype not in ARRAY_DTYPES:
|
|
110
|
+
raise ValueError(f"{path}: unsupported_type: unknown array dtype {dtype!r}")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Explicit row-count, duration, and event-time grouping declarations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from calc_flow.symbolic.domains import namespace_error
|
|
9
|
+
from calc_flow.symbolic.expr import ColumnExpr
|
|
10
|
+
from calc_flow.symbolic.types import require_positive_int
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class RowFrame:
|
|
15
|
+
"""A positive row-count rolling frame ``rows [i - size + 1, i]``."""
|
|
16
|
+
|
|
17
|
+
size: int
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
require_positive_int(self.size, "RowFrame.size")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class DurationFrame:
|
|
25
|
+
"""A positive exact-microseconds duration frame ``(t - micros, t]``."""
|
|
26
|
+
|
|
27
|
+
micros: int
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
require_positive_int(self.micros, "DurationFrame.micros")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
34
|
+
class EventTimeBucket:
|
|
35
|
+
"""A fixed UTC event-time bucket of exact positive width."""
|
|
36
|
+
|
|
37
|
+
event_time: ColumnExpr
|
|
38
|
+
width_micros: int
|
|
39
|
+
partition_by: tuple[ColumnExpr, ...] = ()
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if not isinstance(self.event_time, ColumnExpr):
|
|
43
|
+
raise namespace_error(
|
|
44
|
+
"event_time_bucket", "event_time", "ColumnExpr", self.event_time
|
|
45
|
+
)
|
|
46
|
+
require_positive_int(
|
|
47
|
+
self.width_micros, "calc_flow.symbolic.event_time_bucket.width_micros"
|
|
48
|
+
)
|
|
49
|
+
object.__setattr__(self, "partition_by", tuple(self.partition_by))
|
|
50
|
+
for index, column in enumerate(self.partition_by):
|
|
51
|
+
if not isinstance(column, ColumnExpr):
|
|
52
|
+
raise namespace_error(
|
|
53
|
+
"event_time_bucket",
|
|
54
|
+
f"partition_by[{index}]",
|
|
55
|
+
"ColumnExpr",
|
|
56
|
+
column,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
61
|
+
class CrossSectionGroup:
|
|
62
|
+
"""One complete cross-section grouping declaration."""
|
|
63
|
+
|
|
64
|
+
event_time: ColumnExpr
|
|
65
|
+
bucket: EventTimeBucket | None
|
|
66
|
+
partition_by: tuple[ColumnExpr, ...]
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
if not isinstance(self.event_time, ColumnExpr):
|
|
70
|
+
raise namespace_error(
|
|
71
|
+
"cross_section_group",
|
|
72
|
+
"event_time",
|
|
73
|
+
"ColumnExpr",
|
|
74
|
+
self.event_time,
|
|
75
|
+
)
|
|
76
|
+
if self.bucket is not None and not isinstance(self.bucket, EventTimeBucket):
|
|
77
|
+
raise namespace_error(
|
|
78
|
+
"cross_section_group",
|
|
79
|
+
"bucket",
|
|
80
|
+
"EventTimeBucket | None",
|
|
81
|
+
self.bucket,
|
|
82
|
+
)
|
|
83
|
+
object.__setattr__(self, "partition_by", tuple(self.partition_by))
|
|
84
|
+
for index, column in enumerate(self.partition_by):
|
|
85
|
+
if not isinstance(column, ColumnExpr):
|
|
86
|
+
raise namespace_error(
|
|
87
|
+
"cross_section_group",
|
|
88
|
+
f"partition_by[{index}]",
|
|
89
|
+
"ColumnExpr",
|
|
90
|
+
column,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def rows(size: int, /) -> RowFrame:
|
|
95
|
+
"""Declare a row-count rolling frame."""
|
|
96
|
+
|
|
97
|
+
require_positive_int(size, "calc_flow.symbolic.rows.size")
|
|
98
|
+
return RowFrame(size)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def duration(micros: int, /) -> DurationFrame:
|
|
102
|
+
"""Declare a duration rolling frame in exact microseconds."""
|
|
103
|
+
|
|
104
|
+
require_positive_int(micros, "calc_flow.symbolic.duration.micros")
|
|
105
|
+
return DurationFrame(micros)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def exact_time(
|
|
109
|
+
event_time: ColumnExpr,
|
|
110
|
+
/,
|
|
111
|
+
*,
|
|
112
|
+
partition_by: Sequence[ColumnExpr] = (),
|
|
113
|
+
) -> CrossSectionGroup:
|
|
114
|
+
"""Group by one exact event-time value plus the ordered partition key."""
|
|
115
|
+
|
|
116
|
+
if not isinstance(event_time, ColumnExpr):
|
|
117
|
+
raise namespace_error("exact_time", "event_time", "ColumnExpr", event_time)
|
|
118
|
+
columns = tuple(partition_by)
|
|
119
|
+
for index, column in enumerate(columns):
|
|
120
|
+
if not isinstance(column, ColumnExpr):
|
|
121
|
+
raise namespace_error(
|
|
122
|
+
"exact_time", f"partition_by[{index}]", "ColumnExpr", column
|
|
123
|
+
)
|
|
124
|
+
return CrossSectionGroup(event_time, None, columns)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def event_time_bucket(
|
|
128
|
+
event_time: ColumnExpr,
|
|
129
|
+
/,
|
|
130
|
+
*,
|
|
131
|
+
width_micros: int,
|
|
132
|
+
partition_by: Sequence[ColumnExpr] = (),
|
|
133
|
+
) -> CrossSectionGroup:
|
|
134
|
+
"""Group by fixed UTC buckets of exact positive width."""
|
|
135
|
+
|
|
136
|
+
if not isinstance(event_time, ColumnExpr):
|
|
137
|
+
raise namespace_error(
|
|
138
|
+
"event_time_bucket", "event_time", "ColumnExpr", event_time
|
|
139
|
+
)
|
|
140
|
+
require_positive_int(
|
|
141
|
+
width_micros, "calc_flow.symbolic.event_time_bucket.width_micros"
|
|
142
|
+
)
|
|
143
|
+
columns = tuple(partition_by)
|
|
144
|
+
for index, column in enumerate(columns):
|
|
145
|
+
if not isinstance(column, ColumnExpr):
|
|
146
|
+
raise namespace_error(
|
|
147
|
+
"event_time_bucket",
|
|
148
|
+
f"partition_by[{index}]",
|
|
149
|
+
"ColumnExpr",
|
|
150
|
+
column,
|
|
151
|
+
)
|
|
152
|
+
bucket = EventTimeBucket(event_time, width_micros, columns)
|
|
153
|
+
return CrossSectionGroup(event_time, bucket, columns)
|
calc_flow/udf.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _validate_scalar_udf_registration(
|
|
8
|
+
input_types: Sequence[str], function: Any
|
|
9
|
+
) -> tuple[str, ...]:
|
|
10
|
+
if isinstance(input_types, (str, bytes)) or not isinstance(input_types, Sequence):
|
|
11
|
+
raise TypeError("input_types must be a sequence of Arrow type names")
|
|
12
|
+
copied_types = tuple(input_types)
|
|
13
|
+
if len(copied_types) > 64:
|
|
14
|
+
raise ValueError("input_types must contain at most 64 entries")
|
|
15
|
+
if not all(isinstance(value, str) for value in copied_types):
|
|
16
|
+
raise TypeError("input_types must contain only Arrow type names")
|
|
17
|
+
if not callable(function):
|
|
18
|
+
raise TypeError("function must be callable")
|
|
19
|
+
return copied_types
|