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,553 @@
1
+ """Messaging patterns (roadmap §4.2).
2
+
3
+ Pulsar-inspired messaging features modeled as graph factories:
4
+ - ``topic()`` for append-only topic streams
5
+ - ``subscription()`` for cursor-based consumers
6
+ - ``job_queue()`` for queue claim/ack flow
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from types import MappingProxyType
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from graphrefly.core.protocol import MessageType
16
+ from graphrefly.core.sugar import derived, effect, state
17
+ from graphrefly.extra.data_structures import (
18
+ Versioned,
19
+ reactive_list,
20
+ reactive_log,
21
+ reactive_map,
22
+ )
23
+ from graphrefly.graph.graph import Graph
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Mapping
27
+
28
+ DEFAULT_MAX_PER_PUMP = 2_147_483_647
29
+
30
+
31
+ def _messaging_meta(kind: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
32
+ out: dict[str, Any] = {"messaging": True, "messaging_type": kind}
33
+ if extra:
34
+ out.update(extra)
35
+ return out
36
+
37
+
38
+ def _keepalive(n: Any) -> None:
39
+ n.subscribe(lambda _msgs: None)
40
+
41
+
42
+ def _tuple_snapshot(raw: Any) -> tuple[Any, ...]:
43
+ if isinstance(raw, tuple):
44
+ return raw
45
+ if isinstance(raw, list):
46
+ return tuple(raw)
47
+ return ()
48
+
49
+
50
+ def _require_non_negative_int(value: Any, *, label: str) -> int:
51
+ if isinstance(value, bool):
52
+ raise ValueError(f"{label} must be a non-negative integer")
53
+ if not isinstance(value, int):
54
+ raise ValueError(f"{label} must be a non-negative integer")
55
+ if value < 0:
56
+ raise ValueError(f"{label} must be a non-negative integer")
57
+ return value
58
+
59
+
60
+ class TopicGraph(Graph):
61
+ """Append-only topic graph with retained event log and latest value node."""
62
+
63
+ __slots__ = ("_log", "events", "latest")
64
+
65
+ def __init__(
66
+ self,
67
+ name: str,
68
+ *,
69
+ opts: dict[str, Any] | None = None,
70
+ retained_limit: int | None = None,
71
+ ) -> None:
72
+ super().__init__(name, opts)
73
+ self._log = reactive_log(max_size=retained_limit, name="events")
74
+ self.events = self._log.entries
75
+ self.add("events", self.events)
76
+
77
+ def compute_latest(deps: list[Any], _actions: Any) -> Any:
78
+ raw = deps[0]
79
+ entries = _tuple_snapshot(raw.value if isinstance(raw, Versioned) else ())
80
+ return entries[-1] if len(entries) > 0 else None
81
+
82
+ self.latest = derived(
83
+ [self.events],
84
+ compute_latest,
85
+ initial=None,
86
+ name="latest",
87
+ meta=_messaging_meta("topic_latest"),
88
+ )
89
+ self.add("latest", self.latest)
90
+ self.connect("events", "latest")
91
+ _keepalive(self.latest)
92
+
93
+ def publish(self, value: Any) -> None:
94
+ self._log.append(value)
95
+
96
+ def retained(self) -> tuple[Any, ...]:
97
+ snap = self.events.get()
98
+ entries = _tuple_snapshot(snap.value if isinstance(snap, Versioned) else ())
99
+ return entries
100
+
101
+
102
+ class SubscriptionGraph(Graph):
103
+ """Cursor-based consumer graph over a topic."""
104
+
105
+ __slots__ = ("available", "cursor", "source")
106
+
107
+ def __init__(
108
+ self,
109
+ name: str,
110
+ topic_graph: TopicGraph,
111
+ *,
112
+ opts: dict[str, Any] | None = None,
113
+ cursor: int = 0,
114
+ ) -> None:
115
+ super().__init__(name, opts)
116
+ initial_cursor = _require_non_negative_int(cursor, label="subscription cursor")
117
+ self.mount("topic", topic_graph)
118
+ topic_events = topic_graph.events
119
+ self.source = derived(
120
+ [topic_events],
121
+ lambda deps, _a: deps[0],
122
+ initial=topic_events.get(),
123
+ name="source",
124
+ meta=_messaging_meta("subscription_source"),
125
+ )
126
+ self.add("source", self.source)
127
+ self.cursor = state(
128
+ initial_cursor,
129
+ name="cursor",
130
+ describe_kind="state",
131
+ meta=_messaging_meta("subscription_cursor"),
132
+ )
133
+ self.add("cursor", self.cursor)
134
+
135
+ def compute_available(deps: list[Any], _actions: Any) -> tuple[Any, ...]:
136
+ source_snapshot = deps[0]
137
+ idx = max(0, int(deps[1] if deps[1] is not None else 0))
138
+ entries = _tuple_snapshot(
139
+ source_snapshot.value if isinstance(source_snapshot, Versioned) else (),
140
+ )
141
+ return entries[idx:]
142
+
143
+ self.available = derived(
144
+ [self.source, self.cursor],
145
+ compute_available,
146
+ initial=(),
147
+ name="available",
148
+ meta=_messaging_meta("subscription_available"),
149
+ )
150
+ self.add("available", self.available)
151
+ self.connect("topic::events", "source")
152
+ self.connect("source", "available")
153
+ self.connect("cursor", "available")
154
+ _keepalive(self.source)
155
+ _keepalive(self.available)
156
+
157
+ def ack(self, count: int | None = None) -> int:
158
+ available = _tuple_snapshot(self.available.get())
159
+ total = len(available)
160
+ requested = (
161
+ total
162
+ if count is None
163
+ else _require_non_negative_int(
164
+ count,
165
+ label="subscription ack count",
166
+ )
167
+ )
168
+ step = min(total, requested)
169
+ current_raw = self.cursor.get()
170
+ current = int(current_raw) if current_raw is not None else 0
171
+ if step <= 0:
172
+ return current
173
+ next_cursor = current + step
174
+ self.cursor.down([(MessageType.DATA, next_cursor)])
175
+ return next_cursor
176
+
177
+ def pull(self, limit: int | None = None, *, ack: bool = False) -> tuple[Any, ...]:
178
+ available = _tuple_snapshot(self.available.get())
179
+ n = (
180
+ len(available)
181
+ if limit is None
182
+ else _require_non_negative_int(
183
+ limit,
184
+ label="subscription pull limit",
185
+ )
186
+ )
187
+ out = available[:n]
188
+ if ack and len(out) > 0:
189
+ self.ack(len(out))
190
+ return out
191
+
192
+
193
+ type JobState = str
194
+
195
+
196
+ @dataclass(frozen=True, slots=True)
197
+ class JobEnvelope:
198
+ """Queued/inflight job record used by :class:`JobQueueGraph`."""
199
+
200
+ id: str
201
+ payload: Any
202
+ attempts: int
203
+ metadata: Mapping[str, Any]
204
+ state: JobState
205
+
206
+
207
+ class JobQueueGraph(Graph):
208
+ """Queue graph with claim/ack/nack primitives."""
209
+
210
+ __slots__ = ("_jobs", "_pending", "_seq", "depth", "jobs", "pending")
211
+
212
+ def __init__(self, name: str, *, opts: dict[str, Any] | None = None) -> None:
213
+ super().__init__(name, opts)
214
+ self._pending = reactive_list(name="pending")
215
+ self._jobs = reactive_map(name="jobs")
216
+ self._seq = 0
217
+ self.pending = self._pending.items
218
+ self.jobs = self._jobs.data
219
+ self.add("pending", self.pending)
220
+ self.add("jobs", self.jobs)
221
+ self.depth = derived(
222
+ [self.pending],
223
+ lambda deps, _a: len(deps[0].value if isinstance(deps[0], Versioned) else ()),
224
+ initial=0,
225
+ name="depth",
226
+ meta=_messaging_meta("queue_depth"),
227
+ )
228
+ self.add("depth", self.depth)
229
+ self.connect("pending", "depth")
230
+ _keepalive(self.depth)
231
+
232
+ def _job_get(self, job_id: str) -> JobEnvelope | None:
233
+ snapshot = self._jobs.data.get()
234
+ rows = snapshot.value if isinstance(snapshot, Versioned) else {}
235
+ return rows.get(job_id)
236
+
237
+ def enqueue(
238
+ self,
239
+ payload: Any,
240
+ *,
241
+ job_id: str | None = None,
242
+ metadata: dict[str, Any] | None = None,
243
+ ) -> str:
244
+ if job_id is None:
245
+ self._seq += 1
246
+ job_id = f"{self.name}-{self._seq}"
247
+ if self._job_get(job_id) is not None:
248
+ raise ValueError(f'job_queue("{self.name}"): duplicate job id {job_id!r}')
249
+ job = JobEnvelope(
250
+ id=job_id,
251
+ payload=payload,
252
+ attempts=0,
253
+ metadata=MappingProxyType(dict(metadata or {})),
254
+ state="queued",
255
+ )
256
+ self._jobs.set(job_id, job)
257
+ self._pending.append(job_id)
258
+ return job_id
259
+
260
+ def claim(self, limit: int = 1) -> tuple[JobEnvelope, ...]:
261
+ max_items = _require_non_negative_int(limit, label="job queue claim limit")
262
+ if max_items == 0:
263
+ return ()
264
+ out: list[JobEnvelope] = []
265
+ while len(out) < max_items:
266
+ pending_snapshot = self.pending.get()
267
+ ids = pending_snapshot.value if isinstance(pending_snapshot, Versioned) else ()
268
+ if len(ids) == 0:
269
+ break
270
+ job_id = self._pending.pop(0)
271
+ current = self._job_get(job_id)
272
+ if current is None or current.state != "queued":
273
+ continue
274
+ inflight = JobEnvelope(
275
+ id=current.id,
276
+ payload=current.payload,
277
+ attempts=current.attempts + 1,
278
+ metadata=current.metadata,
279
+ state="inflight",
280
+ )
281
+ self._jobs.set(job_id, inflight)
282
+ out.append(inflight)
283
+ return tuple(out)
284
+
285
+ def ack(self, job_id: str) -> bool:
286
+ current = self._job_get(job_id)
287
+ if current is None or current.state != "inflight":
288
+ return False
289
+ self._jobs.delete(job_id)
290
+ return True
291
+
292
+ def nack(self, job_id: str, *, requeue: bool = True) -> bool:
293
+ current = self._job_get(job_id)
294
+ if current is None or current.state != "inflight":
295
+ return False
296
+ if requeue:
297
+ self._jobs.set(
298
+ job_id,
299
+ JobEnvelope(
300
+ id=current.id,
301
+ payload=current.payload,
302
+ attempts=current.attempts,
303
+ metadata=current.metadata,
304
+ state="queued",
305
+ ),
306
+ )
307
+ self._pending.append(job_id)
308
+ return True
309
+ self._jobs.delete(job_id)
310
+ return True
311
+
312
+
313
+ class JobFlowGraph(Graph):
314
+ """Autonomous multi-stage queue chain graph."""
315
+
316
+ __slots__ = ("_completed", "_queues", "_stage_names", "completed", "completed_count")
317
+
318
+ def __init__(
319
+ self,
320
+ name: str,
321
+ *,
322
+ opts: dict[str, Any] | None = None,
323
+ stages: tuple[str, ...] | list[str] | None = None,
324
+ max_per_pump: int | None = None,
325
+ ) -> None:
326
+ super().__init__(name, opts)
327
+ raw = tuple(stages or ("incoming", "processing", "done"))
328
+ stage_names = tuple(v.strip() for v in raw)
329
+ if len(stage_names) < 2:
330
+ raise ValueError(f'job_flow("{name}"): requires at least 2 stages')
331
+ if len(set(stage_names)) != len(stage_names):
332
+ raise ValueError(f'job_flow("{name}"): stage names must be unique')
333
+ self._stage_names = stage_names
334
+ self._queues: dict[str, JobQueueGraph] = {}
335
+ for stage in self._stage_names:
336
+ queue = job_queue(f"{name}-{stage}")
337
+ self._queues[stage] = queue
338
+ self.mount(stage, queue)
339
+ self._completed = reactive_log(name="completed")
340
+ self.completed = self._completed.entries
341
+ self.add("completed", self.completed)
342
+ self.completed_count = derived(
343
+ [self.completed],
344
+ lambda deps, _a: len(deps[0].value if isinstance(deps[0], Versioned) else ()),
345
+ initial=0,
346
+ name="completed_count",
347
+ meta=_messaging_meta("job_flow_completed_count"),
348
+ )
349
+ self.add("completed_count", self.completed_count)
350
+ self.connect("completed", "completed_count")
351
+ _keepalive(self.completed_count)
352
+
353
+ raw_max = DEFAULT_MAX_PER_PUMP if max_per_pump is None else max_per_pump
354
+ max_items = max(1, _require_non_negative_int(raw_max, label="job flow max_per_pump"))
355
+ for index, stage in enumerate(self._stage_names):
356
+ current = self.queue(stage)
357
+ nxt = (
358
+ self.queue(self._stage_names[index + 1])
359
+ if index + 1 < len(self._stage_names)
360
+ else None
361
+ )
362
+
363
+ def run_stage(
364
+ _deps: list[Any],
365
+ _actions: Any,
366
+ *,
367
+ s: str = stage,
368
+ cur: JobQueueGraph = current,
369
+ nxt_q: JobQueueGraph | None = nxt,
370
+ m: int = max_items,
371
+ ) -> None:
372
+ moved = 0
373
+ while moved < m:
374
+ claimed = cur.claim(1)
375
+ if len(claimed) == 0:
376
+ break
377
+ job = claimed[0]
378
+ if nxt_q is not None:
379
+ nxt_q.enqueue(
380
+ job.payload,
381
+ metadata={**dict(job.metadata), "job_flow_from": s},
382
+ )
383
+ else:
384
+ self._completed.append(job)
385
+ cur.ack(job.id)
386
+ moved += 1
387
+
388
+ pump = effect(
389
+ [current.pending],
390
+ run_stage,
391
+ name=f"pump_{stage}",
392
+ meta=_messaging_meta("job_flow_pump"),
393
+ )
394
+ self.add(f"pump_{stage}", pump)
395
+ self.connect(f"{stage}::pending", f"pump_{stage}")
396
+ _keepalive(pump)
397
+
398
+ def stages(self) -> tuple[str, ...]:
399
+ return self._stage_names
400
+
401
+ def queue(self, stage: str) -> JobQueueGraph:
402
+ try:
403
+ return self._queues[stage]
404
+ except KeyError as exc:
405
+ raise ValueError(f'job_flow("{self.name}"): unknown stage {stage!r}') from exc
406
+
407
+ def enqueue(
408
+ self,
409
+ payload: Any,
410
+ *,
411
+ job_id: str | None = None,
412
+ metadata: dict[str, Any] | None = None,
413
+ ) -> str:
414
+ first = self._stage_names[0]
415
+ return self.queue(first).enqueue(payload, job_id=job_id, metadata=metadata)
416
+
417
+ def retained_completed(self) -> tuple[JobEnvelope, ...]:
418
+ snap = self.completed.get()
419
+ entries = _tuple_snapshot(snap.value if isinstance(snap, Versioned) else ())
420
+ return tuple(entries)
421
+
422
+
423
+ class TopicBridgeGraph(Graph):
424
+ """Autonomous cursor-based topic relay graph."""
425
+
426
+ __slots__ = ("_source_sub", "_target", "bridged_count")
427
+
428
+ def __init__(
429
+ self,
430
+ name: str,
431
+ source_topic: TopicGraph,
432
+ target_topic: TopicGraph,
433
+ *,
434
+ opts: dict[str, Any] | None = None,
435
+ cursor: int = 0,
436
+ max_per_pump: int | None = None,
437
+ map_fn: Any | None = None,
438
+ ) -> None:
439
+ super().__init__(name, opts)
440
+ self._source_sub = subscription(f"{name}-subscription", source_topic, cursor=cursor)
441
+ self._target = target_topic
442
+ self.mount("subscription", self._source_sub)
443
+ self.bridged_count = state(
444
+ 0,
445
+ name="bridged_count",
446
+ describe_kind="state",
447
+ meta=_messaging_meta("topic_bridge_count"),
448
+ )
449
+ self.add("bridged_count", self.bridged_count)
450
+
451
+ raw_max = DEFAULT_MAX_PER_PUMP if max_per_pump is None else max_per_pump
452
+ max_items = max(1, _require_non_negative_int(raw_max, label="topic bridge max_per_pump"))
453
+ mapper = map_fn or (lambda value: value)
454
+
455
+ def run_bridge(_deps: list[Any], _actions: Any) -> None:
456
+ available = self._source_sub.pull(max_items, ack=True)
457
+ if len(available) == 0:
458
+ return
459
+ bridged = 0
460
+ for value in available:
461
+ mapped = mapper(value)
462
+ if mapped is None:
463
+ continue
464
+ self._target.publish(mapped)
465
+ bridged += 1
466
+ if bridged > 0:
467
+ current_raw = self.bridged_count.get()
468
+ current = int(current_raw) if current_raw is not None else 0
469
+ self.bridged_count.down([(MessageType.DATA, current + bridged)])
470
+
471
+ pump = effect(
472
+ [self._source_sub.available],
473
+ run_bridge,
474
+ name="pump",
475
+ meta=_messaging_meta("topic_bridge_pump"),
476
+ )
477
+ self.add("pump", pump)
478
+ self.connect("subscription::available", "pump")
479
+ _keepalive(pump)
480
+
481
+
482
+ def topic(
483
+ name: str,
484
+ *,
485
+ opts: dict[str, Any] | None = None,
486
+ retained_limit: int | None = None,
487
+ ) -> TopicGraph:
488
+ """Create a Pulsar-inspired topic graph (retained append-only stream)."""
489
+ return TopicGraph(name, opts=opts, retained_limit=retained_limit)
490
+
491
+
492
+ def subscription(
493
+ name: str,
494
+ topic_graph: TopicGraph,
495
+ *,
496
+ opts: dict[str, Any] | None = None,
497
+ cursor: int = 0,
498
+ ) -> SubscriptionGraph:
499
+ """Create a cursor-based subscription graph over a topic."""
500
+ return SubscriptionGraph(name, topic_graph, opts=opts, cursor=cursor)
501
+
502
+
503
+ def job_queue(name: str, *, opts: dict[str, Any] | None = None) -> JobQueueGraph:
504
+ """Create a Pulsar-inspired job queue graph with claim/ack/nack workflow."""
505
+ return JobQueueGraph(name, opts=opts)
506
+
507
+
508
+ def job_flow(
509
+ name: str,
510
+ *,
511
+ opts: dict[str, Any] | None = None,
512
+ stages: tuple[str, ...] | list[str] | None = None,
513
+ max_per_pump: int | None = None,
514
+ ) -> JobFlowGraph:
515
+ """Create an autonomous multi-stage queue chain graph."""
516
+ return JobFlowGraph(name, opts=opts, stages=stages, max_per_pump=max_per_pump)
517
+
518
+
519
+ def topic_bridge(
520
+ name: str,
521
+ source_topic: TopicGraph,
522
+ target_topic: TopicGraph,
523
+ *,
524
+ opts: dict[str, Any] | None = None,
525
+ cursor: int = 0,
526
+ max_per_pump: int | None = None,
527
+ map_fn: Any | None = None,
528
+ ) -> TopicBridgeGraph:
529
+ """Create an autonomous cursor-based topic relay graph."""
530
+ return TopicBridgeGraph(
531
+ name,
532
+ source_topic,
533
+ target_topic,
534
+ opts=opts,
535
+ cursor=cursor,
536
+ max_per_pump=max_per_pump,
537
+ map_fn=map_fn,
538
+ )
539
+
540
+
541
+ __all__ = [
542
+ "JobEnvelope",
543
+ "JobFlowGraph",
544
+ "JobQueueGraph",
545
+ "SubscriptionGraph",
546
+ "TopicBridgeGraph",
547
+ "TopicGraph",
548
+ "job_flow",
549
+ "job_queue",
550
+ "subscription",
551
+ "topic",
552
+ "topic_bridge",
553
+ ]