graphrefly 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.
Files changed (47) hide show
  1. graphrefly/__init__.py +160 -0
  2. graphrefly/compat/__init__.py +18 -0
  3. graphrefly/compat/async_utils.py +228 -0
  4. graphrefly/compat/asyncio_runner.py +89 -0
  5. graphrefly/compat/trio_runner.py +81 -0
  6. graphrefly/core/__init__.py +142 -0
  7. graphrefly/core/clock.py +20 -0
  8. graphrefly/core/dynamic_node.py +749 -0
  9. graphrefly/core/guard.py +277 -0
  10. graphrefly/core/meta.py +149 -0
  11. graphrefly/core/node.py +963 -0
  12. graphrefly/core/protocol.py +460 -0
  13. graphrefly/core/runner.py +107 -0
  14. graphrefly/core/subgraph_locks.py +296 -0
  15. graphrefly/core/sugar.py +138 -0
  16. graphrefly/core/versioning.py +193 -0
  17. graphrefly/extra/__init__.py +313 -0
  18. graphrefly/extra/adapters.py +2149 -0
  19. graphrefly/extra/backoff.py +287 -0
  20. graphrefly/extra/backpressure.py +113 -0
  21. graphrefly/extra/checkpoint.py +307 -0
  22. graphrefly/extra/composite.py +303 -0
  23. graphrefly/extra/cron.py +133 -0
  24. graphrefly/extra/data_structures.py +707 -0
  25. graphrefly/extra/resilience.py +727 -0
  26. graphrefly/extra/sources.py +766 -0
  27. graphrefly/extra/tier1.py +1067 -0
  28. graphrefly/extra/tier2.py +1802 -0
  29. graphrefly/graph/__init__.py +31 -0
  30. graphrefly/graph/graph.py +2249 -0
  31. graphrefly/integrations/__init__.py +1 -0
  32. graphrefly/integrations/fastapi.py +767 -0
  33. graphrefly/patterns/__init__.py +5 -0
  34. graphrefly/patterns/ai.py +2132 -0
  35. graphrefly/patterns/cqrs.py +515 -0
  36. graphrefly/patterns/memory.py +639 -0
  37. graphrefly/patterns/messaging.py +553 -0
  38. graphrefly/patterns/orchestration.py +536 -0
  39. graphrefly/patterns/reactive_layout/__init__.py +81 -0
  40. graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
  41. graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
  42. graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
  43. graphrefly/py.typed +1 -0
  44. graphrefly-0.1.0.dist-info/METADATA +253 -0
  45. graphrefly-0.1.0.dist-info/RECORD +47 -0
  46. graphrefly-0.1.0.dist-info/WHEEL +4 -0
  47. graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,536 @@
1
+ """Orchestration patterns (roadmap §4.1).
2
+
3
+ Domain-layer helpers that build workflow shapes on top of core + extra primitives.
4
+ Export under ``graphrefly.patterns.orchestration`` to avoid collisions with Phase 2
5
+ operator names like ``gate`` and ``for_each``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import threading
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Any, TypedDict
14
+
15
+ from graphrefly.core.node import Node, NodeActions, node
16
+ from graphrefly.core.protocol import MessageType
17
+ from graphrefly.graph.graph import GRAPH_META_SEGMENT, PATH_SEP, Graph
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ type StepRef = str | Node[Any]
23
+
24
+
25
+ class BranchResult(TypedDict):
26
+ branch: str
27
+ value: Any
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class SensorControls[T]:
32
+ """Result bundle for :func:`sensor`."""
33
+
34
+ node: Node[T]
35
+ push: Callable[[T], None]
36
+ error: Callable[[BaseException | Any], None]
37
+ complete: Callable[[], None]
38
+
39
+
40
+ def _resolve_dep(graph: Graph, dep: StepRef) -> tuple[Node[Any], str | None]:
41
+ if isinstance(dep, str):
42
+ return graph.resolve(dep), dep
43
+ path = _find_registered_node_path(graph, dep)
44
+ if path is None:
45
+ msg = (
46
+ "orchestration dep node must already be registered in the graph so "
47
+ "explicit edges can be recorded; pass a string path or register the node first"
48
+ )
49
+ raise ValueError(msg)
50
+ return dep, path
51
+
52
+
53
+ def _find_registered_node_path(graph: Graph, target: Node[Any]) -> str | None:
54
+ described = graph.describe()
55
+ meta_segment = f"{PATH_SEP}{GRAPH_META_SEGMENT}{PATH_SEP}"
56
+ for path in sorted(str(k) for k in described["nodes"]):
57
+ if meta_segment in path:
58
+ continue
59
+ try:
60
+ if graph.resolve(path) is target:
61
+ return path
62
+ except KeyError:
63
+ continue
64
+ return None
65
+
66
+
67
+ def _register_step(
68
+ graph: Graph,
69
+ step_name: str,
70
+ step: Node[Any],
71
+ dep_paths: list[str],
72
+ ) -> None:
73
+ graph.add(step_name, step)
74
+ for dep_path in dep_paths:
75
+ graph.connect(dep_path, step_name)
76
+
77
+
78
+ def _base_meta(kind: str, meta: dict[str, Any] | None) -> dict[str, Any]:
79
+ out: dict[str, Any] = {"orchestration": True, "orchestration_type": kind}
80
+ if meta:
81
+ out.update(meta)
82
+ return out
83
+
84
+
85
+ def _coerce_loop_iterations(raw: Any) -> int:
86
+ parsed: float
87
+ if isinstance(raw, str):
88
+ trimmed = raw.strip()
89
+ if len(trimmed) == 0:
90
+ parsed = 0.0
91
+ else:
92
+ try:
93
+ parsed = float(trimmed)
94
+ except ValueError:
95
+ parsed = float("nan")
96
+ elif raw is None:
97
+ parsed = 0.0
98
+ else:
99
+ try:
100
+ parsed = float(raw)
101
+ except (TypeError, ValueError):
102
+ parsed = float("nan")
103
+ if not math.isfinite(parsed):
104
+ return 1
105
+ count = int(parsed)
106
+ if count < 0:
107
+ return 0
108
+ return count
109
+
110
+
111
+ def pipeline(name: str, *, opts: dict[str, Any] | None = None) -> Graph:
112
+ """Create an orchestration graph container."""
113
+ return Graph(name, opts)
114
+
115
+
116
+ def task(
117
+ graph: Graph,
118
+ name: str,
119
+ run: Any,
120
+ *,
121
+ deps: list[StepRef] | None = None,
122
+ meta: dict[str, Any] | None = None,
123
+ **node_opts: Any,
124
+ ) -> Node[Any]:
125
+ """Register a workflow task node."""
126
+ resolved = [_resolve_dep(graph, dep) for dep in (deps or [])]
127
+ dep_nodes = [n for n, _ in resolved]
128
+ dep_paths = [p for _n, p in resolved if p is not None]
129
+ step = node(
130
+ dep_nodes,
131
+ run,
132
+ name=name,
133
+ describe_kind="derived",
134
+ meta=_base_meta("task", meta),
135
+ **node_opts,
136
+ )
137
+ _register_step(graph, name, step, dep_paths)
138
+ return step
139
+
140
+
141
+ def branch(
142
+ graph: Graph,
143
+ name: str,
144
+ source: StepRef,
145
+ predicate: Any,
146
+ *,
147
+ meta: dict[str, Any] | None = None,
148
+ **node_opts: Any,
149
+ ) -> Node[BranchResult]:
150
+ """Register a branch step that tags each value as ``then`` or ``else``."""
151
+ source_node, source_path = _resolve_dep(graph, source)
152
+
153
+ def compute(deps: list[Any], _actions: NodeActions) -> BranchResult:
154
+ value = deps[0]
155
+ return {
156
+ "branch": "then" if bool(predicate(value)) else "else",
157
+ "value": value,
158
+ }
159
+
160
+ step = node(
161
+ [source_node],
162
+ compute,
163
+ name=name,
164
+ describe_kind="derived",
165
+ meta=_base_meta("branch", meta),
166
+ **node_opts,
167
+ )
168
+ _register_step(graph, name, step, [source_path] if source_path else [])
169
+ return step
170
+
171
+
172
+ def gate(
173
+ graph: Graph,
174
+ name: str,
175
+ source: StepRef,
176
+ control: StepRef,
177
+ *,
178
+ meta: dict[str, Any] | None = None,
179
+ **node_opts: Any,
180
+ ) -> Node[Any]:
181
+ """Register a value-level gate step controlled by a boolean signal."""
182
+ source_node, source_path = _resolve_dep(graph, source)
183
+ control_node, control_path = _resolve_dep(graph, control)
184
+
185
+ def compute(_deps: list[Any], actions: NodeActions) -> Any:
186
+ if not bool(control_node.get()):
187
+ actions.down([(MessageType.RESOLVED,)])
188
+ return None
189
+ return source_node.get()
190
+
191
+ step = node(
192
+ [source_node, control_node],
193
+ compute,
194
+ name=name,
195
+ describe_kind="operator",
196
+ meta=_base_meta("gate", meta),
197
+ **node_opts,
198
+ )
199
+ paths = [p for p in (source_path, control_path) if p is not None]
200
+ _register_step(graph, name, step, paths)
201
+ return step
202
+
203
+
204
+ def approval(
205
+ graph: Graph,
206
+ name: str,
207
+ source: StepRef,
208
+ approver: StepRef,
209
+ *,
210
+ is_approved: Any | None = None,
211
+ meta: dict[str, Any] | None = None,
212
+ **node_opts: Any,
213
+ ) -> Node[Any]:
214
+ """Register an approval gate (human/LLM/system signal controls value flow)."""
215
+ source_node, source_path = _resolve_dep(graph, source)
216
+ approver_node, approver_path = _resolve_dep(graph, approver)
217
+ approved_fn = is_approved if is_approved is not None else (lambda value: bool(value))
218
+
219
+ def compute(_deps: list[Any], actions: NodeActions) -> Any:
220
+ if not bool(approved_fn(approver_node.get())):
221
+ actions.down([(MessageType.RESOLVED,)])
222
+ return None
223
+ return source_node.get()
224
+
225
+ step = node(
226
+ [source_node, approver_node],
227
+ compute,
228
+ name=name,
229
+ describe_kind="operator",
230
+ meta=_base_meta("approval", meta),
231
+ **node_opts,
232
+ )
233
+ paths = [p for p in (source_path, approver_path) if p is not None]
234
+ _register_step(graph, name, step, paths)
235
+ return step
236
+
237
+
238
+ def for_each(
239
+ graph: Graph,
240
+ name: str,
241
+ source: StepRef,
242
+ run: Callable[[Any, NodeActions], None],
243
+ *,
244
+ meta: dict[str, Any] | None = None,
245
+ **node_opts: Any,
246
+ ) -> Node[Any]:
247
+ """Register a workflow side-effect step that remains graph-observable."""
248
+ source_node, source_path = _resolve_dep(graph, source)
249
+ terminated = [False]
250
+
251
+ def on_message(msg: Any, index: int, actions: NodeActions) -> bool:
252
+ if terminated[0]:
253
+ return True
254
+ if index != 0:
255
+ actions.down([msg])
256
+ if msg[0] is MessageType.COMPLETE or msg[0] is MessageType.ERROR:
257
+ terminated[0] = True
258
+ return True
259
+ if msg[0] is MessageType.DATA:
260
+ try:
261
+ run(msg[1], actions)
262
+ actions.down([msg])
263
+ except BaseException as err:
264
+ terminated[0] = True
265
+ actions.down([(MessageType.ERROR, err)])
266
+ return True
267
+ actions.down([msg])
268
+ if msg[0] is MessageType.COMPLETE or msg[0] is MessageType.ERROR:
269
+ terminated[0] = True
270
+ return True
271
+
272
+ step = node(
273
+ [source_node],
274
+ lambda _deps, _actions: None,
275
+ name=name,
276
+ describe_kind="effect",
277
+ complete_when_deps_complete=False,
278
+ on_message=on_message,
279
+ meta=_base_meta("for_each", meta),
280
+ **node_opts,
281
+ )
282
+ _register_step(graph, name, step, [source_path] if source_path else [])
283
+ return step
284
+
285
+
286
+ def join(
287
+ graph: Graph,
288
+ name: str,
289
+ deps: list[StepRef],
290
+ *,
291
+ meta: dict[str, Any] | None = None,
292
+ **node_opts: Any,
293
+ ) -> Node[Any]:
294
+ """Register a join step that emits the latest tuple of dependency values."""
295
+ resolved = [_resolve_dep(graph, dep) for dep in deps]
296
+ dep_nodes = [n for n, _ in resolved]
297
+ dep_paths = [p for _n, p in resolved if p is not None]
298
+ step = node(
299
+ dep_nodes,
300
+ lambda values, _a: tuple(values),
301
+ name=name,
302
+ describe_kind="derived",
303
+ meta=_base_meta("join", meta),
304
+ **node_opts,
305
+ )
306
+ _register_step(graph, name, step, dep_paths)
307
+ return step
308
+
309
+
310
+ def loop(
311
+ graph: Graph,
312
+ name: str,
313
+ source: StepRef,
314
+ iterate: Callable[[Any, int, NodeActions], Any],
315
+ *,
316
+ iterations: int | StepRef = 1,
317
+ meta: dict[str, Any] | None = None,
318
+ **node_opts: Any,
319
+ ) -> Node[Any]:
320
+ """Register a loop step that applies ``iterate`` N times per source value."""
321
+ source_node, source_path = _resolve_dep(graph, source)
322
+ iter_node: Node[Any] | None = None
323
+ iter_path: str | None = None
324
+ if isinstance(iterations, int):
325
+ static_iterations = iterations
326
+ else:
327
+ static_iterations = None
328
+ iter_node, iter_path = _resolve_dep(graph, iterations)
329
+
330
+ def compute(_deps: list[Any], actions: NodeActions) -> Any:
331
+ value = source_node.get()
332
+ raw: Any = (
333
+ static_iterations
334
+ if static_iterations is not None
335
+ else (iter_node.get() if iter_node is not None else 1)
336
+ )
337
+ count = _coerce_loop_iterations(raw)
338
+ for i in range(count):
339
+ value = iterate(value, i, actions)
340
+ return value
341
+
342
+ deps_list: list[Node[Any]] = [source_node] + ([iter_node] if iter_node is not None else [])
343
+ step = node(
344
+ deps_list,
345
+ compute,
346
+ name=name,
347
+ describe_kind="derived",
348
+ meta=_base_meta("loop", meta),
349
+ **node_opts,
350
+ )
351
+ _register_step(graph, name, step, [p for p in (source_path, iter_path) if p is not None])
352
+ return step
353
+
354
+
355
+ def sub_pipeline(
356
+ graph: Graph,
357
+ name: str,
358
+ child_or_build: Graph | Callable[[Graph], None] | None = None,
359
+ *,
360
+ opts: dict[str, Any] | None = None,
361
+ ) -> Graph:
362
+ """Mount and return a child workflow graph."""
363
+ child = child_or_build if isinstance(child_or_build, Graph) else pipeline(name, opts=opts)
364
+ if callable(child_or_build):
365
+ child_or_build(child)
366
+ graph.mount(name, child)
367
+ return child
368
+
369
+
370
+ def sensor(
371
+ graph: Graph,
372
+ name: str,
373
+ *,
374
+ initial: Any | None = None,
375
+ meta: dict[str, Any] | None = None,
376
+ **node_opts: Any,
377
+ ) -> SensorControls[Any]:
378
+ """Register a producer-like sensor source and return imperative controls."""
379
+ src = node(
380
+ [],
381
+ lambda _deps, _actions: None,
382
+ name=name,
383
+ initial=initial,
384
+ describe_kind="producer",
385
+ meta=_base_meta("sensor", meta),
386
+ **node_opts,
387
+ )
388
+ _register_step(graph, name, src, [])
389
+ return SensorControls(
390
+ node=src,
391
+ push=lambda value: src.down([(MessageType.DATA, value)]),
392
+ error=lambda err: src.down([(MessageType.ERROR, err)]),
393
+ complete=lambda: src.down([(MessageType.COMPLETE,)]),
394
+ )
395
+
396
+
397
+ def wait(
398
+ graph: Graph,
399
+ name: str,
400
+ source: StepRef,
401
+ seconds: float,
402
+ *,
403
+ meta: dict[str, Any] | None = None,
404
+ **node_opts: Any,
405
+ ) -> Node[Any]:
406
+ """Register a delayed-forwarding step (value-level wait)."""
407
+ source_node, source_path = _resolve_dep(graph, source)
408
+ timers: set[threading.Timer] = set()
409
+ lock = threading.Lock()
410
+ terminated = [False]
411
+ completed = [False]
412
+
413
+ def clear_all() -> None:
414
+ with lock:
415
+ current = list(timers)
416
+ timers.clear()
417
+ terminated[0] = True
418
+ for timer in current:
419
+ timer.cancel()
420
+
421
+ def on_message(msg: Any, index: int, actions: NodeActions) -> bool:
422
+ if terminated[0]:
423
+ return True
424
+ if index != 0:
425
+ actions.down([msg])
426
+ if msg[0] is MessageType.COMPLETE or msg[0] is MessageType.ERROR:
427
+ terminated[0] = True
428
+ return True
429
+ if msg[0] is MessageType.DATA:
430
+
431
+ def fire() -> None:
432
+ actions.down([msg])
433
+ should_complete = False
434
+ with lock:
435
+ timers.discard(timer)
436
+ should_complete = completed[0] and len(timers) == 0
437
+ if should_complete:
438
+ actions.down([(MessageType.COMPLETE,)])
439
+
440
+ timer = threading.Timer(seconds, fire)
441
+ with lock:
442
+ timers.add(timer)
443
+ timer.start()
444
+ return True
445
+ if msg[0] is MessageType.COMPLETE:
446
+ with lock:
447
+ terminated[0] = True
448
+ completed[0] = True
449
+ done_now = len(timers) == 0
450
+ if done_now:
451
+ actions.down([(MessageType.COMPLETE,)])
452
+ return True
453
+ if msg[0] is MessageType.ERROR:
454
+ clear_all()
455
+ actions.down([msg])
456
+ return True
457
+ actions.down([msg])
458
+ return True
459
+
460
+ def compute(_deps: list[Any], _actions: NodeActions) -> Any:
461
+ return clear_all
462
+
463
+ step = node(
464
+ [source_node],
465
+ compute,
466
+ name=name,
467
+ initial=source_node.get(),
468
+ describe_kind="operator",
469
+ complete_when_deps_complete=False,
470
+ on_message=on_message,
471
+ meta=_base_meta("wait", meta),
472
+ **node_opts,
473
+ )
474
+ _register_step(graph, name, step, [source_path] if source_path else [])
475
+ return step
476
+
477
+
478
+ def on_failure(
479
+ graph: Graph,
480
+ name: str,
481
+ source: StepRef,
482
+ recover: Callable[[BaseException | Any, NodeActions], Any],
483
+ *,
484
+ meta: dict[str, Any] | None = None,
485
+ **node_opts: Any,
486
+ ) -> Node[Any]:
487
+ """Register an error-recovery step for a source."""
488
+ source_node, source_path = _resolve_dep(graph, source)
489
+ terminated = [False]
490
+
491
+ def on_message(msg: Any, _index: int, actions: NodeActions) -> bool:
492
+ if terminated[0]:
493
+ return True
494
+ if msg[0] is MessageType.ERROR:
495
+ try:
496
+ actions.emit(recover(msg[1], actions))
497
+ except BaseException as err:
498
+ terminated[0] = True
499
+ actions.down([(MessageType.ERROR, err)])
500
+ return True
501
+ actions.down([msg])
502
+ if msg[0] is MessageType.COMPLETE:
503
+ terminated[0] = True
504
+ return True
505
+
506
+ step = node(
507
+ [source_node],
508
+ lambda _deps, _actions: None,
509
+ name=name,
510
+ describe_kind="operator",
511
+ complete_when_deps_complete=False,
512
+ on_message=on_message,
513
+ meta=_base_meta("on_failure", meta),
514
+ **node_opts,
515
+ )
516
+ _register_step(graph, name, step, [source_path] if source_path else [])
517
+ return step
518
+
519
+
520
+ __all__ = [
521
+ "BranchResult",
522
+ "SensorControls",
523
+ "StepRef",
524
+ "approval",
525
+ "branch",
526
+ "for_each",
527
+ "gate",
528
+ "join",
529
+ "loop",
530
+ "on_failure",
531
+ "pipeline",
532
+ "sensor",
533
+ "sub_pipeline",
534
+ "task",
535
+ "wait",
536
+ ]
@@ -0,0 +1,81 @@
1
+ """Reactive layout pattern — standalone importable module.
2
+
3
+ Usage::
4
+
5
+ from graphrefly.patterns.reactive_layout import reactive_layout, CliMeasureAdapter
6
+ """
7
+
8
+ from graphrefly.patterns.reactive_layout.measurement_adapters import (
9
+ CliMeasureAdapter,
10
+ ImageSizeAdapter,
11
+ PillowMeasureAdapter,
12
+ PrecomputedAdapter,
13
+ SvgBoundsAdapter,
14
+ )
15
+ from graphrefly.patterns.reactive_layout.reactive_block_layout import (
16
+ BlockAdapters,
17
+ ContentBlock,
18
+ ImageBlock,
19
+ ImageMeasurer,
20
+ MeasuredBlock,
21
+ PositionedBlock,
22
+ ReactiveBlockLayoutBundle,
23
+ SvgBlock,
24
+ SvgMeasurer,
25
+ TextBlock,
26
+ compute_block_flow,
27
+ compute_total_height,
28
+ measure_block,
29
+ measure_blocks,
30
+ reactive_block_layout,
31
+ )
32
+ from graphrefly.patterns.reactive_layout.reactive_layout import (
33
+ CharPosition,
34
+ LayoutLine,
35
+ LineBreaksResult,
36
+ MeasurementAdapter,
37
+ PreparedSegment,
38
+ ReactiveLayoutBundle,
39
+ SegmentBreakKind,
40
+ analyze_and_measure,
41
+ compute_char_positions,
42
+ compute_line_breaks,
43
+ reactive_layout,
44
+ )
45
+
46
+ __all__ = [
47
+ # reactive_layout
48
+ "CharPosition",
49
+ "LayoutLine",
50
+ "LineBreaksResult",
51
+ "MeasurementAdapter",
52
+ "PreparedSegment",
53
+ "ReactiveLayoutBundle",
54
+ "SegmentBreakKind",
55
+ "analyze_and_measure",
56
+ "compute_char_positions",
57
+ "compute_line_breaks",
58
+ "reactive_layout",
59
+ # reactive_block_layout
60
+ "BlockAdapters",
61
+ "ContentBlock",
62
+ "ImageBlock",
63
+ "ImageMeasurer",
64
+ "MeasuredBlock",
65
+ "PositionedBlock",
66
+ "ReactiveBlockLayoutBundle",
67
+ "SvgBlock",
68
+ "SvgMeasurer",
69
+ "TextBlock",
70
+ "compute_block_flow",
71
+ "compute_total_height",
72
+ "measure_block",
73
+ "measure_blocks",
74
+ "reactive_block_layout",
75
+ # measurement_adapters
76
+ "CliMeasureAdapter",
77
+ "ImageSizeAdapter",
78
+ "PillowMeasureAdapter",
79
+ "PrecomputedAdapter",
80
+ "SvgBoundsAdapter",
81
+ ]