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/pipeline.py ADDED
@@ -0,0 +1,1123 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from enum import StrEnum
8
+ from threading import RLock
9
+ from types import MappingProxyType
10
+ from typing import Any, Literal
11
+ from uuid import uuid4
12
+
13
+ from calc_flow import _native
14
+ from calc_flow.capabilities import (
15
+ ProviderArrayRules,
16
+ ProviderOptionsSchema,
17
+ RuntimeCapabilities,
18
+ _StatelessProviderLifecycle,
19
+ runtime_capabilities,
20
+ )
21
+ from calc_flow.join_spec import (
22
+ JoinSideWire,
23
+ JoinStateLimits,
24
+ JoinTimeBounds,
25
+ bounds_wire,
26
+ join_wire_spec,
27
+ limits_wire,
28
+ require_distinct_prefixes,
29
+ require_equal_key_counts,
30
+ require_event_time_columns,
31
+ require_join_bounds,
32
+ require_join_limits,
33
+ timedelta_micros,
34
+ )
35
+ from calc_flow.store import _copy_json_value, _run_blocking
36
+
37
+ JSONValue = None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"]
38
+ UdfReference = tuple[str, str, str]
39
+ _SYMBOLIC_COMPILE_CACHE_MAX_ENTRIES = 128
40
+
41
+
42
+ def _canonical(value: Mapping[str, Any]) -> str:
43
+ return json.dumps(value, allow_nan=False, separators=(",", ":"), sort_keys=True)
44
+
45
+
46
+ def _udf_documents(references: Sequence[UdfReference]) -> list[dict[str, str]]:
47
+ documents: list[dict[str, str]] = []
48
+ for reference in references:
49
+ if len(reference) != 3:
50
+ raise ValueError("UDF references must be (provider, name, version) tuples")
51
+ provider, name, version = reference
52
+ if not all(isinstance(value, str) for value in reference):
53
+ raise TypeError("UDF reference values must be strings")
54
+ documents.append(
55
+ {
56
+ "kind": "data_fusion_scalar",
57
+ "name": name,
58
+ "provider": provider,
59
+ "version": version,
60
+ }
61
+ )
62
+ return documents
63
+
64
+
65
+ def _node_inputs(node: Mapping[str, Any]) -> tuple[str, ...]:
66
+ configured = node.get("input_ports", [])
67
+ if configured:
68
+ return tuple(port["name"] for port in configured)
69
+ operator = node["operator"]
70
+ if operator["kind"] == "expression":
71
+ return ("input",)
72
+ if operator["kind"] == "sql":
73
+ return tuple(operator["aliases"])
74
+ return ()
75
+
76
+
77
+ def _data_sources(project: Mapping[str, Any]) -> list[dict[str, JSONValue]]:
78
+ graph = project["graph"]
79
+ connected = {
80
+ (edge["target_node"], edge.get("target_port", "input"))
81
+ for edge in graph["edges"]
82
+ }
83
+ endpoints = sorted(
84
+ (node["id"], port)
85
+ for node in graph["nodes"]
86
+ for port in _node_inputs(node)
87
+ if (node["id"], port) not in connected
88
+ )
89
+ counts: dict[str, int] = {}
90
+ for _, port in endpoints:
91
+ counts[port] = counts.get(port, 0) + 1
92
+ names = sorted(
93
+ port if counts[port] == 1 else f"{node_id}.{port}"
94
+ for node_id, port in endpoints
95
+ )
96
+ return [
97
+ {"data": [], "format": "inline_json", "id": f"source_{index}", "input": name}
98
+ for index, name in enumerate(names, start=1)
99
+ ]
100
+
101
+
102
+ def _updated_project(project_json: str, update: Any) -> str:
103
+ project = json.loads(project_json)
104
+ update(project)
105
+ project["data_sources"] = _data_sources(project)
106
+ return _canonical(project)
107
+
108
+
109
+ def _validate_stateless_lifecycle_values(values: Mapping[str, object]) -> None:
110
+ for field_name, value in values.items():
111
+ if type(value) is not bool:
112
+ raise TypeError(
113
+ f"{field_name} must be an exact bool; found {type(value).__name__}"
114
+ )
115
+ if not values["microbatch_invariant"]:
116
+ raise ValueError("stateless stream providers must be micro-batch invariant")
117
+
118
+
119
+ def _registered_batch_provider(
120
+ registrations: Sequence[dict[str, Any]],
121
+ provider: str,
122
+ name: str,
123
+ version: str,
124
+ ) -> dict[str, Any]:
125
+ matches = [
126
+ registration
127
+ for registration in registrations
128
+ if registration["kind"] == "provider"
129
+ and registration["provider"] == provider
130
+ and registration["name"] == name
131
+ and registration["version"] == version
132
+ ]
133
+ if len(matches) != 1:
134
+ raise ValueError(
135
+ "stateless stream registration requires one existing batch "
136
+ f"provider {provider}:{name}@{version}"
137
+ )
138
+ return matches[0]
139
+
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class Runtime:
143
+ _inner: _native.Runtime = field(default_factory=_native.Runtime, repr=False)
144
+ _registration_lock: RLock = field(default_factory=RLock, repr=False, compare=False)
145
+ _session_id: str = field(
146
+ default_factory=lambda: str(uuid4()), repr=False, compare=False
147
+ )
148
+ _registrations: list[dict[str, Any]] = field(
149
+ default_factory=list, repr=False, compare=False
150
+ )
151
+ _symbolic_compile_cache: dict[object, object] = field(
152
+ default_factory=dict, repr=False, compare=False
153
+ )
154
+
155
+ def _cached_symbolic_compile(
156
+ self, key: object, factory: Callable[[], object], /
157
+ ) -> object:
158
+ """Return one immutable compiled plan for a deterministic cache key."""
159
+
160
+ with self._registration_lock:
161
+ cached = self._symbolic_compile_cache.get(key)
162
+ if cached is not None:
163
+ return cached
164
+ compiled = factory()
165
+ if len(self._symbolic_compile_cache) >= _SYMBOLIC_COMPILE_CACHE_MAX_ENTRIES:
166
+ oldest = next(iter(self._symbolic_compile_cache))
167
+ del self._symbolic_compile_cache[oldest]
168
+ self._symbolic_compile_cache[key] = compiled
169
+ return compiled
170
+
171
+ def _invalidate_symbolic_compile_cache(self) -> None:
172
+ self._symbolic_compile_cache.clear()
173
+
174
+ def register_provider(
175
+ self,
176
+ provider: str,
177
+ name: str,
178
+ version: str,
179
+ callback: Any,
180
+ *,
181
+ options_schema: ProviderOptionsSchema | None = None,
182
+ accepts_context: bool = False,
183
+ ) -> None:
184
+ if type(accepts_context) is not bool:
185
+ raise TypeError(
186
+ "accepts_context must be an exact bool; "
187
+ f"found {type(accepts_context).__name__}"
188
+ )
189
+ if options_schema is not None and not isinstance(
190
+ options_schema, ProviderOptionsSchema
191
+ ):
192
+ raise TypeError(
193
+ "options_schema must be a ProviderOptionsSchema or None; "
194
+ f"found {type(options_schema).__name__}"
195
+ )
196
+ with self._registration_lock:
197
+ self._inner.register_provider(
198
+ provider,
199
+ name,
200
+ version,
201
+ callback,
202
+ accepts_context=accepts_context,
203
+ )
204
+ registration = {
205
+ "kind": "provider",
206
+ "provider": provider,
207
+ "name": name,
208
+ "version": version,
209
+ "callback": callback,
210
+ "options_schema": options_schema,
211
+ }
212
+ if accepts_context:
213
+ registration["accepts_context"] = True
214
+ self._registrations.append(registration)
215
+ self._invalidate_symbolic_compile_cache()
216
+
217
+ def _register_mapping_provider(
218
+ self,
219
+ provider: str,
220
+ name: str,
221
+ version: str,
222
+ callback: Any,
223
+ *,
224
+ input_ports: Sequence[tuple[str, str]],
225
+ output_ports: Sequence[tuple[str, str]],
226
+ options_schema: ProviderOptionsSchema | None = None,
227
+ accepts_context: bool = False,
228
+ ) -> None:
229
+ if type(accepts_context) is not bool:
230
+ raise TypeError(
231
+ "accepts_context must be an exact bool; "
232
+ f"found {type(accepts_context).__name__}"
233
+ )
234
+ if options_schema is not None and not isinstance(
235
+ options_schema, ProviderOptionsSchema
236
+ ):
237
+ raise TypeError(
238
+ "options_schema must be a ProviderOptionsSchema or None; "
239
+ f"found {type(options_schema).__name__}"
240
+ )
241
+ copied_inputs = tuple((port, kind) for port, kind in input_ports)
242
+ copied_outputs = tuple((port, kind) for port, kind in output_ports)
243
+ with self._registration_lock:
244
+ self._inner._register_mapping_provider(
245
+ provider,
246
+ name,
247
+ version,
248
+ callback,
249
+ input_ports=copied_inputs,
250
+ output_ports=copied_outputs,
251
+ accepts_context=accepts_context,
252
+ )
253
+ registration = {
254
+ "kind": "provider",
255
+ "provider_mode": "mapping",
256
+ "provider": provider,
257
+ "name": name,
258
+ "version": version,
259
+ "callback": callback,
260
+ "input_ports": copied_inputs,
261
+ "output_ports": copied_outputs,
262
+ "options_schema": options_schema,
263
+ }
264
+ if accepts_context:
265
+ registration["accepts_context"] = True
266
+ self._registrations.append(registration)
267
+ self._invalidate_symbolic_compile_cache()
268
+
269
+ def _register_stateless_stream_provider(
270
+ self,
271
+ provider: str,
272
+ name: str,
273
+ version: str,
274
+ callback: Any,
275
+ *,
276
+ microbatch_invariant: bool,
277
+ deterministic: bool,
278
+ replay_safe: bool,
279
+ supports_static_inputs: bool,
280
+ array_rules: ProviderArrayRules,
281
+ ) -> None:
282
+ _validate_stateless_lifecycle_values(
283
+ {
284
+ "microbatch_invariant": microbatch_invariant,
285
+ "deterministic": deterministic,
286
+ "replay_safe": replay_safe,
287
+ "supports_static_inputs": supports_static_inputs,
288
+ }
289
+ )
290
+ if not isinstance(array_rules, ProviderArrayRules):
291
+ raise TypeError(
292
+ "array_rules must be a ProviderArrayRules; "
293
+ f"found {type(array_rules).__name__}"
294
+ )
295
+ with self._registration_lock:
296
+ registration = _registered_batch_provider(
297
+ self._registrations, provider, name, version
298
+ )
299
+ self._inner._register_stateless_stream_provider(
300
+ provider,
301
+ name,
302
+ version,
303
+ callback,
304
+ microbatch_invariant=microbatch_invariant,
305
+ deterministic=deterministic,
306
+ replay_safe=replay_safe,
307
+ )
308
+ registration["stream_lifecycle"] = _StatelessProviderLifecycle(
309
+ deterministic=deterministic,
310
+ replay_safe=replay_safe,
311
+ supports_static_inputs=supports_static_inputs,
312
+ array_rules=array_rules,
313
+ )
314
+ self._invalidate_symbolic_compile_cache()
315
+
316
+ def _register_stateless_stream_mapping_provider(
317
+ self,
318
+ provider: str,
319
+ name: str,
320
+ version: str,
321
+ callback: Any,
322
+ *,
323
+ microbatch_invariant: bool,
324
+ deterministic: bool,
325
+ replay_safe: bool,
326
+ supports_static_inputs: bool,
327
+ array_rules: ProviderArrayRules,
328
+ ) -> None:
329
+ _validate_stateless_lifecycle_values(
330
+ {
331
+ "microbatch_invariant": microbatch_invariant,
332
+ "deterministic": deterministic,
333
+ "replay_safe": replay_safe,
334
+ "supports_static_inputs": supports_static_inputs,
335
+ }
336
+ )
337
+ if not isinstance(array_rules, ProviderArrayRules):
338
+ raise TypeError(
339
+ "array_rules must be a ProviderArrayRules; "
340
+ f"found {type(array_rules).__name__}"
341
+ )
342
+ with self._registration_lock:
343
+ registration = _registered_batch_provider(
344
+ self._registrations, provider, name, version
345
+ )
346
+ if registration.get("provider_mode") != "mapping":
347
+ raise ValueError(
348
+ "stateless stream mapping registration requires an existing "
349
+ f"mapping provider {provider}:{name}@{version}"
350
+ )
351
+ self._inner._register_stateless_stream_mapping_provider(
352
+ provider,
353
+ name,
354
+ version,
355
+ callback,
356
+ input_ports=registration["input_ports"],
357
+ output_ports=registration["output_ports"],
358
+ microbatch_invariant=microbatch_invariant,
359
+ deterministic=deterministic,
360
+ replay_safe=replay_safe,
361
+ )
362
+ registration["stream_lifecycle"] = _StatelessProviderLifecycle(
363
+ deterministic=deterministic,
364
+ replay_safe=replay_safe,
365
+ supports_static_inputs=supports_static_inputs,
366
+ array_rules=array_rules,
367
+ )
368
+ self._invalidate_symbolic_compile_cache()
369
+
370
+ def register_scalar_udf(
371
+ self,
372
+ *,
373
+ provider: str,
374
+ name: str,
375
+ version: str,
376
+ input_types: Sequence[str],
377
+ return_type: str,
378
+ volatility: str,
379
+ function: Any,
380
+ ) -> None:
381
+ from calc_flow.udf import _validate_scalar_udf_registration
382
+
383
+ copied_types = _validate_scalar_udf_registration(input_types, function)
384
+ with self._registration_lock:
385
+ self._inner.register_scalar_udf(
386
+ provider=provider,
387
+ name=name,
388
+ version=version,
389
+ input_types=copied_types,
390
+ return_type=return_type,
391
+ volatility=volatility,
392
+ function=function,
393
+ )
394
+ self._registrations.append(
395
+ {
396
+ "kind": "scalar_udf",
397
+ "provider": provider,
398
+ "name": name,
399
+ "version": version,
400
+ "input_types": tuple(copied_types),
401
+ "return_type": return_type,
402
+ "volatility": volatility,
403
+ "function": function,
404
+ }
405
+ )
406
+ self._invalidate_symbolic_compile_cache()
407
+
408
+ def _copied_registrations(self) -> tuple[dict[str, Any], ...]:
409
+ return tuple(
410
+ {
411
+ **registration,
412
+ **(
413
+ {"input_types": tuple(registration["input_types"])}
414
+ if registration["kind"] == "scalar_udf"
415
+ else (
416
+ {
417
+ "input_ports": tuple(registration["input_ports"]),
418
+ "output_ports": tuple(registration["output_ports"]),
419
+ }
420
+ if registration.get("provider_mode") == "mapping"
421
+ else {}
422
+ )
423
+ ),
424
+ }
425
+ for registration in self._registrations
426
+ )
427
+
428
+ def _registration_snapshot(self) -> tuple[dict[str, Any], ...]:
429
+ """Return successful trusted registrations as defensive plain records."""
430
+ with self._registration_lock:
431
+ return self._copied_registrations()
432
+
433
+ def catalog(self) -> list[dict[str, Any]]:
434
+ return self._inner.catalog()
435
+
436
+ def capabilities(self) -> RuntimeCapabilities:
437
+ snapshot, _ = self._capability_registration_snapshot()
438
+ return snapshot
439
+
440
+ def _capability_registration_snapshot(
441
+ self,
442
+ ) -> tuple[RuntimeCapabilities, tuple[dict[str, Any], ...]]:
443
+ """Capture safe metadata and private worker records at one revision."""
444
+ with self._registration_lock:
445
+ registrations = self._copied_registrations()
446
+ snapshot = runtime_capabilities(
447
+ session_id=self._session_id,
448
+ revision=len(registrations),
449
+ package_version=_native.version(),
450
+ registrations=registrations,
451
+ )
452
+ return snapshot, registrations
453
+
454
+ def validation_report(self, project_json: str) -> dict[str, Any]:
455
+ if not isinstance(project_json, str):
456
+ raise TypeError("project_json must be a string")
457
+ return self._inner.validation_report(project_json)
458
+
459
+ def compile_project(self, project_json: str) -> BatchExecutionPlan:
460
+ if not isinstance(project_json, str):
461
+ raise TypeError("project_json must be a string")
462
+ return BatchExecutionPlan(self._inner.compile_project(project_json))
463
+
464
+ def compile_batch_project(self, project_json: str) -> BatchExecutionPlan:
465
+ """Compile one canonical project-v3 batch document."""
466
+ return self.compile_project(project_json)
467
+
468
+ def compile_stream_project(
469
+ self,
470
+ project_json: str,
471
+ *,
472
+ requirements: StreamRequirements | None = None,
473
+ ) -> StreamExecutionPlan:
474
+ """Compile project JSON into a continuous plan owned by this runtime."""
475
+ if not isinstance(project_json, str):
476
+ raise TypeError("project_json must be a string")
477
+ selected = StreamRequirements() if requirements is None else requirements
478
+ if not isinstance(selected, StreamRequirements):
479
+ raise TypeError(
480
+ "requirements must be a calc_flow.StreamRequirements or None"
481
+ )
482
+ delivery = {
483
+ output: guarantee.value for output, guarantee in selected.delivery.items()
484
+ }
485
+ canonical = _native.validate_project_json(project_json)
486
+ project = json.loads(canonical)
487
+ runtime_options = project["runtime"]["options"]
488
+ state = project["state"]
489
+ return StreamExecutionPlan(
490
+ self._inner.compile_stream_project(canonical, delivery),
491
+ _ProjectStreamSettings(
492
+ state_root=state["root"],
493
+ retained_epochs=state["retention"],
494
+ checkpoint_interval_ms=runtime_options["checkpoint_interval_ms"],
495
+ max_batch_rows=runtime_options["max_batch_rows"],
496
+ max_batch_bytes=runtime_options["max_batch_bytes"],
497
+ ),
498
+ )
499
+
500
+ def _compile_stream_graph_project(
501
+ self,
502
+ project_json: str,
503
+ *,
504
+ requirements: StreamRequirements | None = None,
505
+ ) -> StreamExecutionPlan:
506
+ selected = StreamRequirements() if requirements is None else requirements
507
+ delivery = {
508
+ output: guarantee.value for output, guarantee in selected.delivery.items()
509
+ }
510
+ return StreamExecutionPlan(
511
+ self._inner._compile_stream_graph_project(project_json, delivery)
512
+ )
513
+
514
+
515
+ @dataclass(frozen=True, slots=True)
516
+ class BatchExecutionPlan:
517
+ _inner: _native.ExecutionPlan = field(repr=False)
518
+
519
+ @property
520
+ def name(self) -> str:
521
+ return self._inner.name
522
+
523
+ @property
524
+ def fingerprint(self) -> str:
525
+ return self._inner.fingerprint
526
+
527
+ def execute(
528
+ self,
529
+ inputs: Mapping[str, _native.Batch],
530
+ *,
531
+ options: _native.ExecutionOptions | None = None,
532
+ ) -> _native.RunResult:
533
+ try:
534
+ asyncio.get_running_loop()
535
+ except RuntimeError:
536
+ pass
537
+ else:
538
+ raise RuntimeError(
539
+ "execute() cannot run inside an event loop; use execute_async()"
540
+ )
541
+ if options is not None and type(options) is not _native.ExecutionOptions:
542
+ raise TypeError("options must be a calc_flow.ExecutionOptions or None")
543
+ copied = dict(inputs)
544
+ return self._inner.execute(copied, options=options)
545
+
546
+ def execute_async(
547
+ self,
548
+ inputs: Mapping[str, _native.Batch],
549
+ *,
550
+ options: _native.ExecutionOptions | None = None,
551
+ ) -> Awaitable[_native.RunResult]:
552
+ if options is not None and type(options) is not _native.ExecutionOptions:
553
+ raise TypeError("options must be a calc_flow.ExecutionOptions or None")
554
+ copied = dict(inputs)
555
+
556
+ async def execute() -> _native.RunResult:
557
+ native, cancellation = self._inner._execute_async_cancellable(
558
+ copied, options=options
559
+ )
560
+ try:
561
+ return await asyncio.shield(native)
562
+ except asyncio.CancelledError as cancelled:
563
+ native_completed = native.done()
564
+ if native_completed:
565
+ return native.result()
566
+ cancellation.cancel()
567
+ # Cancellation precedence: once the handler passes the
568
+ # terminal-at-entry check, the caller's CancelledError wins
569
+ # over any native outcome observed during the drain. A native
570
+ # failure landing mid-drain is retrieved below (so asyncio
571
+ # never reports it as unretrieved) and then discarded.
572
+ while not native.done():
573
+ try:
574
+ await asyncio.shield(native)
575
+ except asyncio.CancelledError:
576
+ continue
577
+ except Exception:
578
+ break
579
+ if native.done() and not native.cancelled():
580
+ native.exception()
581
+ raise cancelled
582
+
583
+ return execute()
584
+
585
+ async def snapshot_async(self) -> dict[str, Any]:
586
+ state = await self._inner.snapshot_async()
587
+ return _copy_json_value(state, root_mapping=True, label="plan state")
588
+
589
+ def restore_async(self, state: Mapping[str, object]) -> Awaitable[None]:
590
+ copied = _copy_json_value(dict(state), root_mapping=True, label="plan state")
591
+ encoded = json.dumps(copied, separators=(",", ":"), sort_keys=True)
592
+
593
+ async def restore() -> None:
594
+ await self._inner.restore_async(encoded)
595
+
596
+ return restore()
597
+
598
+ async def reset_async(self) -> None:
599
+ await self._inner.reset_async()
600
+
601
+ def snapshot(self) -> dict[str, Any]:
602
+ return _run_blocking(self.snapshot_async, "snapshot_async")
603
+
604
+ def restore(self, state: Mapping[str, object]) -> None:
605
+ return _run_blocking(lambda: self.restore_async(state), "restore_async")
606
+
607
+ def reset(self) -> None:
608
+ return _run_blocking(self.reset_async, "reset_async")
609
+
610
+
611
+ ExecutionPlan = BatchExecutionPlan
612
+
613
+
614
+ class DeliveryGuarantee(StrEnum):
615
+ """Delivery guarantee requested for one external stream output."""
616
+
617
+ BEST_EFFORT = "best_effort"
618
+ AT_LEAST_ONCE = "at_least_once"
619
+ EXACTLY_ONCE = "exactly_once"
620
+
621
+
622
+ @dataclass(frozen=True, slots=True)
623
+ class StreamRequirements:
624
+ """Immutable per-output delivery requirements for stream compilation."""
625
+
626
+ delivery: Mapping[str, DeliveryGuarantee] = field(default_factory=dict)
627
+
628
+ def __post_init__(self) -> None:
629
+ if not isinstance(self.delivery, Mapping):
630
+ raise TypeError(
631
+ "delivery must be a mapping of output names to DeliveryGuarantee values"
632
+ )
633
+ copied = dict(self.delivery)
634
+ for output, guarantee in copied.items():
635
+ if not isinstance(output, str) or not output:
636
+ raise TypeError("delivery output names must be non-empty strings")
637
+ if not isinstance(guarantee, DeliveryGuarantee):
638
+ raise TypeError(
639
+ "delivery guarantees must be calc_flow.DeliveryGuarantee values"
640
+ )
641
+ object.__setattr__(self, "delivery", MappingProxyType(copied))
642
+
643
+
644
+ @dataclass(frozen=True, slots=True)
645
+ class ArrowFieldSpec:
646
+ """One exact project-v3 Arrow field."""
647
+
648
+ name: str
649
+ data_type: str
650
+ nullable: bool = True
651
+
652
+ def __post_init__(self) -> None:
653
+ if not isinstance(self.name, str) or not self.name:
654
+ raise TypeError("name must be a non-empty string")
655
+ if not isinstance(self.data_type, str) or not self.data_type:
656
+ raise TypeError("data_type must be a non-empty string")
657
+ if type(self.nullable) is not bool:
658
+ raise TypeError("nullable must be an exact bool")
659
+
660
+
661
+ def _arrow_fields(
662
+ values: Sequence[ArrowFieldSpec], field_name: str
663
+ ) -> list[dict[str, object]]:
664
+ if isinstance(values, (str, bytes)) or not isinstance(values, Sequence):
665
+ raise TypeError(f"{field_name} must be a sequence of ArrowFieldSpec values")
666
+ copied = tuple(values)
667
+ for index, value in enumerate(copied):
668
+ if not isinstance(value, ArrowFieldSpec):
669
+ raise TypeError(
670
+ f"{field_name} must contain only calc_flow.ArrowFieldSpec values; "
671
+ f"found {type(value).__name__} at index {index}"
672
+ )
673
+ if not copied:
674
+ raise ValueError(f"{field_name} must contain at least one field")
675
+ return [
676
+ {
677
+ "name": value.name,
678
+ "data_type": value.data_type,
679
+ "nullable": value.nullable,
680
+ }
681
+ for value in copied
682
+ ]
683
+
684
+
685
+ def _join_keys(values: Sequence[str], field_name: str) -> list[str]:
686
+ if isinstance(values, (str, bytes)) or not isinstance(values, Sequence):
687
+ raise TypeError(f"{field_name} must be a sequence of column names")
688
+ copied = list(values)
689
+ if not copied or not all(isinstance(value, str) and value for value in copied):
690
+ raise ValueError(f"{field_name} must contain non-empty column names")
691
+ if len(set(copied)) != len(copied):
692
+ raise ValueError(f"{field_name} must contain unique column names")
693
+ return copied
694
+
695
+
696
+ @dataclass(frozen=True, slots=True)
697
+ class _ProjectStreamSettings:
698
+ state_root: str
699
+ retained_epochs: int
700
+ checkpoint_interval_ms: int
701
+ max_batch_rows: int
702
+ max_batch_bytes: int
703
+
704
+
705
+ @dataclass(frozen=True, slots=True)
706
+ class StreamExecutionPlan:
707
+ """Compiled immutable continuous plan consumed by ``StreamingRunner``."""
708
+
709
+ _inner: _native.StreamExecutionPlan = field(repr=False)
710
+ _project_settings: _ProjectStreamSettings | None = field(default=None, repr=False)
711
+
712
+ @property
713
+ def name(self) -> str:
714
+ return self._inner.name
715
+
716
+ @property
717
+ def fingerprint(self) -> str:
718
+ return self._inner.fingerprint
719
+
720
+ @property
721
+ def requirements(self) -> StreamRequirements:
722
+ return StreamRequirements(
723
+ {
724
+ output: DeliveryGuarantee(value)
725
+ for output, value in self._inner.requirements.items()
726
+ }
727
+ )
728
+
729
+ @property
730
+ def source_binding_ids(self) -> tuple[str, ...]:
731
+ return self._inner.source_binding_ids
732
+
733
+ @property
734
+ def static_input_ids(self) -> tuple[str, ...]:
735
+ return self._inner.static_input_ids
736
+
737
+ @property
738
+ def sink_binding_ids(self) -> tuple[str, ...]:
739
+ return self._inner.sink_binding_ids
740
+
741
+
742
+ @dataclass(frozen=True, slots=True, init=False)
743
+ class PipelineBuilder:
744
+ _project_json: str = field(repr=False)
745
+
746
+ def __init__(self, name: str) -> None:
747
+ if not isinstance(name, str):
748
+ raise TypeError("pipeline name must be a string")
749
+ project = {
750
+ "data_sources": [],
751
+ "format_version": 3,
752
+ "id": name,
753
+ "name": name,
754
+ "runtime": {"mode": "batch", "options": {}},
755
+ "graph": {"edges": [], "name": name, "nodes": []},
756
+ }
757
+ object.__setattr__(self, "_project_json", _canonical(project))
758
+
759
+ @classmethod
760
+ def _from_json(cls, project_json: str) -> PipelineBuilder:
761
+ builder = object.__new__(cls)
762
+ object.__setattr__(builder, "_project_json", project_json)
763
+ return builder
764
+
765
+ @property
766
+ def project(self) -> dict[str, Any]:
767
+ return json.loads(self._project_json)
768
+
769
+ def with_datafusion_config(
770
+ self,
771
+ *,
772
+ batch_size: int = 8_192,
773
+ target_partitions: int = 1,
774
+ parallelism_mode: Literal["fixed", "auto"] = "fixed",
775
+ max_partitions: int = 32,
776
+ min_rows_per_partition: int = 65_536,
777
+ small_rows_threshold: int = 10_001,
778
+ enable_rolling_rewrite: bool = True,
779
+ collect_diagnostics: bool = True,
780
+ ) -> PipelineBuilder:
781
+ """Return a builder with an immutable run-scoped DataFusion policy.
782
+
783
+ ``auto`` is opt-in. It uses trusted
784
+ ``calc_flow.datafusion.active_entities`` batch metadata and safely
785
+ falls back to one partition when that statistic is absent or invalid.
786
+ """
787
+ integers = {
788
+ "batch_size": batch_size,
789
+ "target_partitions": target_partitions,
790
+ "max_partitions": max_partitions,
791
+ "min_rows_per_partition": min_rows_per_partition,
792
+ "small_rows_threshold": small_rows_threshold,
793
+ }
794
+ for field_name, value in integers.items():
795
+ if type(value) is not int:
796
+ raise TypeError(f"{field_name} must be a positive integer")
797
+ if value <= 0:
798
+ raise ValueError(f"{field_name} must be a positive integer")
799
+ if type(parallelism_mode) is not str:
800
+ raise TypeError("parallelism_mode must be fixed or auto")
801
+ if parallelism_mode not in {"fixed", "auto"}:
802
+ raise ValueError("parallelism_mode must be fixed or auto")
803
+ for field_name, value in {
804
+ "enable_rolling_rewrite": enable_rolling_rewrite,
805
+ "collect_diagnostics": collect_diagnostics,
806
+ }.items():
807
+ if type(value) is not bool:
808
+ raise TypeError(f"{field_name} must be an exact bool")
809
+
810
+ def update(project: dict[str, Any]) -> None:
811
+ project["graph"]["datafusion"] = {
812
+ **integers,
813
+ "parallelism_mode": parallelism_mode,
814
+ "enable_rolling_rewrite": enable_rolling_rewrite,
815
+ "collect_diagnostics": collect_diagnostics,
816
+ }
817
+
818
+ return self._from_json(_updated_project(self._project_json, update))
819
+
820
+ def expression(
821
+ self,
822
+ name: str,
823
+ expression: str,
824
+ *,
825
+ select: Sequence[str] = (),
826
+ filter: str | None = None,
827
+ udfs: Sequence[UdfReference] = (),
828
+ ) -> PipelineBuilder:
829
+ def add(project: dict[str, Any]) -> None:
830
+ project["graph"]["nodes"].append(
831
+ {
832
+ "id": name,
833
+ "operator": {
834
+ "expression": expression,
835
+ "filter": filter,
836
+ "kind": "expression",
837
+ "select": list(select),
838
+ "udfs": _udf_documents(tuple(udfs)),
839
+ },
840
+ }
841
+ )
842
+
843
+ return self._from_json(_updated_project(self._project_json, add))
844
+
845
+ def sql(
846
+ self,
847
+ name: str,
848
+ query: str,
849
+ *,
850
+ aliases: Sequence[str] = ("input",),
851
+ udfs: Sequence[UdfReference] = (),
852
+ ) -> PipelineBuilder:
853
+ def add(project: dict[str, Any]) -> None:
854
+ project["graph"]["nodes"].append(
855
+ {
856
+ "id": name,
857
+ "operator": {
858
+ "aliases": list(aliases),
859
+ "kind": "sql",
860
+ "query": query,
861
+ "udfs": _udf_documents(tuple(udfs)),
862
+ },
863
+ }
864
+ )
865
+
866
+ return self._from_json(_updated_project(self._project_json, add))
867
+
868
+ def external(
869
+ self,
870
+ node_id: str,
871
+ provider: str,
872
+ name: str,
873
+ version: str,
874
+ options: Mapping[str, object],
875
+ ) -> PipelineBuilder:
876
+ copied_options = dict(options)
877
+
878
+ def add(project: dict[str, Any]) -> None:
879
+ project["graph"]["nodes"].append(
880
+ {
881
+ "id": node_id,
882
+ "input_ports": [
883
+ {
884
+ "kind": "array",
885
+ "name": "input",
886
+ "required": True,
887
+ "schema": [],
888
+ }
889
+ ],
890
+ "operator": {
891
+ "kind": "external",
892
+ "name": name,
893
+ "options": copied_options,
894
+ "provider": provider,
895
+ "version": version,
896
+ },
897
+ "output_ports": [
898
+ {
899
+ "kind": "array",
900
+ "name": "output",
901
+ "required": True,
902
+ "schema": [],
903
+ }
904
+ ],
905
+ }
906
+ )
907
+
908
+ return self._from_json(_updated_project(self._project_json, add))
909
+
910
+ def table_matmul(
911
+ self,
912
+ node_id: str,
913
+ *,
914
+ backend: Literal["numpy", "jax"],
915
+ columns: Sequence[str],
916
+ ) -> PipelineBuilder:
917
+ if backend not in {"numpy", "jax"}:
918
+ raise ValueError("backend must be 'numpy' or 'jax'")
919
+ if isinstance(columns, (str, bytes)) or not isinstance(columns, Sequence):
920
+ raise TypeError("columns must be a sequence of column names")
921
+ copied_columns = list(columns)
922
+ if not copied_columns:
923
+ raise ValueError("columns must contain at least one column name")
924
+ if not all(isinstance(column, str) and column for column in copied_columns):
925
+ raise TypeError("columns must contain non-empty strings")
926
+ if len(set(copied_columns)) != len(copied_columns):
927
+ raise ValueError("columns must be unique")
928
+
929
+ def add(project: dict[str, Any]) -> None:
930
+ project["graph"]["nodes"].append(
931
+ {
932
+ "id": node_id,
933
+ "input_ports": [
934
+ {
935
+ "kind": "table",
936
+ "name": "table",
937
+ "required": True,
938
+ "schema": [],
939
+ },
940
+ {
941
+ "kind": "array",
942
+ "name": "weights",
943
+ "required": True,
944
+ "schema": [],
945
+ },
946
+ ],
947
+ "operator": {
948
+ "kind": "external",
949
+ "name": "table_matmul",
950
+ "options": {"columns": copied_columns},
951
+ "provider": backend,
952
+ "version": "1",
953
+ },
954
+ "output_ports": [
955
+ {
956
+ "kind": "array",
957
+ "name": "output",
958
+ "required": True,
959
+ "schema": [],
960
+ }
961
+ ],
962
+ }
963
+ )
964
+
965
+ return self._from_json(_updated_project(self._project_json, add))
966
+
967
+ def stream_join(
968
+ self,
969
+ name: str,
970
+ *,
971
+ left_schema: Sequence[ArrowFieldSpec],
972
+ right_schema: Sequence[ArrowFieldSpec],
973
+ left_keys: Sequence[str],
974
+ right_keys: Sequence[str],
975
+ left_event_time: str,
976
+ right_event_time: str,
977
+ bounds: JoinTimeBounds,
978
+ limits: JoinStateLimits,
979
+ left_prefix: str = "left",
980
+ right_prefix: str = "right",
981
+ ) -> PipelineBuilder:
982
+ """Return a new builder containing one bounded inner stream Join."""
983
+ if not isinstance(name, str) or not name:
984
+ raise TypeError("name must be a non-empty string")
985
+ copied_left_schema = _arrow_fields(left_schema, "left_schema")
986
+ copied_right_schema = _arrow_fields(right_schema, "right_schema")
987
+ copied_left_keys = _join_keys(left_keys, "left_keys")
988
+ copied_right_keys = _join_keys(right_keys, "right_keys")
989
+ require_equal_key_counts(copied_left_keys, copied_right_keys)
990
+ require_event_time_columns(left_event_time, right_event_time)
991
+ require_join_bounds(bounds)
992
+ require_join_limits(limits)
993
+ require_distinct_prefixes(left_prefix, right_prefix)
994
+
995
+ def add(project: dict[str, Any]) -> None:
996
+ project["graph"]["nodes"].append(
997
+ {
998
+ "id": name,
999
+ "input_ports": [
1000
+ {
1001
+ "kind": "table",
1002
+ "name": "left",
1003
+ "required": True,
1004
+ "schema": copied_left_schema,
1005
+ },
1006
+ {
1007
+ "kind": "table",
1008
+ "name": "right",
1009
+ "required": True,
1010
+ "schema": copied_right_schema,
1011
+ },
1012
+ ],
1013
+ "operator": {
1014
+ "kind": "stream_join",
1015
+ "spec": join_wire_spec(
1016
+ JoinSideWire(
1017
+ keys=tuple(copied_left_keys),
1018
+ event_time=left_event_time,
1019
+ prefix=left_prefix,
1020
+ ),
1021
+ JoinSideWire(
1022
+ keys=tuple(copied_right_keys),
1023
+ event_time=right_event_time,
1024
+ prefix=right_prefix,
1025
+ ),
1026
+ bounds_wire(
1027
+ timedelta_micros(bounds.before, "before"),
1028
+ timedelta_micros(bounds.after, "after"),
1029
+ ),
1030
+ limits_wire(
1031
+ limits.max_state_rows_per_side,
1032
+ limits.max_state_bytes_per_side,
1033
+ limits.max_matches_per_input_batch,
1034
+ ),
1035
+ ),
1036
+ },
1037
+ "output_ports": [],
1038
+ }
1039
+ )
1040
+
1041
+ return self._from_json(_updated_project(self._project_json, add))
1042
+
1043
+ def connect(
1044
+ self,
1045
+ source_node: str,
1046
+ target_node: str,
1047
+ *,
1048
+ source_port: str = "output",
1049
+ target_port: str = "input",
1050
+ ) -> PipelineBuilder:
1051
+ def add(project: dict[str, Any]) -> None:
1052
+ project["graph"]["edges"].append(
1053
+ {
1054
+ "source_node": source_node,
1055
+ "source_port": source_port,
1056
+ "target_node": target_node,
1057
+ "target_port": target_port,
1058
+ }
1059
+ )
1060
+
1061
+ return self._from_json(_updated_project(self._project_json, add))
1062
+
1063
+ def compile_batch(self, runtime: Runtime | None = None) -> BatchExecutionPlan:
1064
+ from calc_flow.array import _validate_provider_options
1065
+
1066
+ for node in self.project["graph"]["nodes"]:
1067
+ operator = node["operator"]
1068
+ if operator["kind"] == "external":
1069
+ _validate_provider_options(
1070
+ operator["provider"],
1071
+ operator["name"],
1072
+ operator["version"],
1073
+ operator["options"],
1074
+ )
1075
+ selected = Runtime() if runtime is None else runtime
1076
+ if not isinstance(selected, Runtime):
1077
+ raise TypeError("runtime must be a calc_flow.Runtime")
1078
+ return selected.compile_batch_project(self._project_json)
1079
+
1080
+ def compile_stream(
1081
+ self,
1082
+ *,
1083
+ requirements: StreamRequirements | None = None,
1084
+ runtime: Runtime | None = None,
1085
+ ) -> StreamExecutionPlan:
1086
+ """Compile a continuous plan with explicit delivery requirements."""
1087
+ selected = Runtime() if runtime is None else runtime
1088
+ if not isinstance(selected, Runtime):
1089
+ raise TypeError("runtime must be a calc_flow.Runtime")
1090
+ project = self.project
1091
+ project["runtime"] = {"mode": "stream", "options": {}}
1092
+ project["data_sources"] = []
1093
+ return selected._compile_stream_graph_project(
1094
+ _canonical(project), requirements=requirements
1095
+ )
1096
+
1097
+
1098
+ def compile_stream_project(
1099
+ project: Mapping[str, object],
1100
+ *,
1101
+ requirements: StreamRequirements | None = None,
1102
+ runtime: Runtime | None = None,
1103
+ ) -> StreamExecutionPlan:
1104
+ """Defensively compile one connector-backed project-v3 document."""
1105
+ from calc_flow.config import ProjectDocument
1106
+
1107
+ if not isinstance(project, Mapping):
1108
+ raise TypeError("project must be a mapping")
1109
+ document = ProjectDocument.model_validate(dict(project))
1110
+ selected = Runtime() if runtime is None else runtime
1111
+ if not isinstance(selected, Runtime):
1112
+ raise TypeError("runtime must be a calc_flow.Runtime")
1113
+ return selected.compile_stream_project(
1114
+ document.canonical_json(), requirements=requirements
1115
+ )
1116
+
1117
+
1118
+ def project_json_schema() -> str:
1119
+ return _native.project_json_schema()
1120
+
1121
+
1122
+ def validate_project_json(project_json: str) -> str:
1123
+ return _native.validate_project_json(project_json)