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,303 @@
1
+ """Composite data patterns (roadmap §3.2b).
2
+
3
+ These helpers compose existing primitives without introducing new protocol semantics.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import AsyncIterable, Awaitable, Iterable, Mapping
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast
11
+
12
+ from graphrefly.core.dynamic_node import dynamic_node
13
+ from graphrefly.core.node import Node
14
+ from graphrefly.core.protocol import MessageType, batch
15
+ from graphrefly.core.sugar import derived, state
16
+ from graphrefly.extra.data_structures import ReactiveMapBundle, reactive_map
17
+ from graphrefly.extra.sources import for_each, from_any, of
18
+ from graphrefly.extra.tier1 import merge
19
+ from graphrefly.extra.tier2 import switch_map
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Callable
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class VerifiableBundle:
27
+ """Result of :func:`verifiable`."""
28
+
29
+ node: Node[Any]
30
+ verified: Node[Any]
31
+ trigger: Node[Any] | None
32
+
33
+
34
+ def verifiable(
35
+ source: Any,
36
+ verify_fn: Callable[[Any], Any],
37
+ *,
38
+ trigger: Any | None = None,
39
+ auto_verify: bool = False,
40
+ initial_verified: Any = None,
41
+ ) -> VerifiableBundle:
42
+ """Compose a value node with a reactive verification companion.
43
+
44
+ Args:
45
+ source: Value source (`Node`, scalar, awaitable, iterable, async iterable).
46
+ verify_fn: Verification function returning a `NodeInput`.
47
+ trigger: Optional reactive trigger; each `DATA` triggers verification.
48
+ auto_verify: When true, source value changes also trigger verification.
49
+ initial_verified: Initial value for the verification companion.
50
+ """
51
+ source_node = from_any(source)
52
+ has_source_versioning = source_node.v is not None
53
+ meta_opts: dict[str, Any] = {"source_version": None} if has_source_versioning else {}
54
+ verified_node = state(initial_verified, **({"meta": meta_opts} if meta_opts else {}))
55
+
56
+ trigger_node: Node[Any] | None = None
57
+ if trigger is not None and auto_verify:
58
+ trigger_node = merge(from_any(trigger), source_node)
59
+ elif trigger is not None:
60
+ trigger_node = from_any(trigger)
61
+ elif auto_verify:
62
+ trigger_node = source_node
63
+
64
+ if trigger_node is not None:
65
+ verify_stream = switch_map(
66
+ lambda _t: _coerce_node_input(verify_fn(source_node.get())),
67
+ )(trigger_node)
68
+
69
+ def _on_verified(value: Any) -> None:
70
+ with batch():
71
+ verified_node.down([(MessageType.DATA, value)])
72
+ # V0 backfill: stamp which source version was verified (§6.0b).
73
+ if has_source_versioning:
74
+ sv = source_node.v
75
+ if sv is not None:
76
+ verified_node.meta["source_version"].down(
77
+ [(MessageType.DATA, {"id": sv.id, "version": sv.version})]
78
+ )
79
+
80
+ for_each(
81
+ verify_stream,
82
+ _on_verified,
83
+ on_error=lambda _err: None,
84
+ )
85
+
86
+ return VerifiableBundle(node=source_node, verified=verified_node, trigger=trigger_node)
87
+
88
+
89
+ class Extraction(TypedDict):
90
+ """Shape consumed by :func:`distill` extraction stages."""
91
+
92
+ upsert: list[dict[str, Any]]
93
+ remove: NotRequired[list[str]]
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class DistillBundle:
98
+ """Result of :func:`distill`."""
99
+
100
+ store: ReactiveMapBundle
101
+ compact: Node[Any]
102
+ size: Node[int]
103
+
104
+
105
+ def _snapshot_map(store: ReactiveMapBundle) -> Mapping[str, Any]:
106
+ snap = store.data.get()
107
+ if snap is None:
108
+ return {}
109
+ if hasattr(snap, "value"):
110
+ return cast("Mapping[str, Any]", snap.value)
111
+ return cast("Mapping[str, Any]", snap)
112
+
113
+
114
+ def _apply_extraction(store: ReactiveMapBundle, extraction: Extraction) -> None:
115
+ upsert = extraction.get("upsert")
116
+ if not isinstance(upsert, list):
117
+ msg = "distill extraction requires upsert: list[{key,value}]"
118
+ raise TypeError(msg)
119
+ with batch():
120
+ for row in upsert:
121
+ store.set(row["key"], row["value"])
122
+ for key in extraction.get("remove", []):
123
+ store.delete(key)
124
+
125
+
126
+ def distill(
127
+ source: Any,
128
+ extract_fn: Callable[[Any, Mapping[str, Any]], Any],
129
+ *,
130
+ score: Callable[[Any, Any], float],
131
+ cost: Callable[[Any], float],
132
+ budget: float = 2000,
133
+ evict: Callable[[str, Any], Any] | None = None,
134
+ consolidate: Callable[[Mapping[str, Any]], Any] | None = None,
135
+ consolidate_trigger: Any | None = None,
136
+ context: Any | None = None,
137
+ map_options: dict[str, Any] | None = None,
138
+ map_name: str | None = None,
139
+ map_default_ttl: float | None = None,
140
+ map_max_size: int | None = None,
141
+ ) -> DistillBundle:
142
+ """Budget-constrained reactive memory composition.
143
+
144
+ Args:
145
+ source: Source stream to distill.
146
+ extract_fn: `(raw, existing) -> Extraction` as `NodeInput`.
147
+ score: Relevance function for compact packing.
148
+ cost: Cost function for compact packing.
149
+ budget: Maximum compact budget (default `2000`).
150
+ evict: Optional reactive eviction predicate per key.
151
+ consolidate: Optional consolidation function.
152
+ consolidate_trigger: Optional trigger for consolidation.
153
+ context: Optional context source affecting compact ranking.
154
+ map_options: Optional dict-style map config parity with TS (`name`,
155
+ `default_ttl`, `max_size`).
156
+ map_name: Optional underlying map node name.
157
+ map_default_ttl: Optional default TTL seconds for underlying map.
158
+ map_max_size: Optional underlying map max size.
159
+ """
160
+ source_node = from_any(source)
161
+ context_node = from_any(context) if context is not None else state(None)
162
+ resolved_default_ttl = (
163
+ map_options["default_ttl"]
164
+ if map_options and "default_ttl" in map_options
165
+ else map_default_ttl
166
+ )
167
+ resolved_max_size = (
168
+ map_options["max_size"] if map_options and "max_size" in map_options else map_max_size
169
+ )
170
+ resolved_name = map_options["name"] if map_options and "name" in map_options else map_name
171
+ store = reactive_map(
172
+ default_ttl=resolved_default_ttl,
173
+ max_size=resolved_max_size,
174
+ name=resolved_name,
175
+ )
176
+
177
+ extraction_stream = switch_map(
178
+ lambda raw: _coerce_node_input(extract_fn(raw, _snapshot_map(store))),
179
+ )(source_node)
180
+ for_each(
181
+ extraction_stream,
182
+ lambda ex: _apply_extraction(store, ex),
183
+ on_error=lambda _err: None,
184
+ )
185
+
186
+ if evict is not None:
187
+ eviction_keys = dynamic_node(
188
+ lambda get: _compute_evictions(get, store, evict),
189
+ )
190
+
191
+ def _delete_keys(keys: list[str]) -> None:
192
+ for key in keys:
193
+ store.delete(key)
194
+
195
+ for_each(
196
+ cast("Node[Any]", eviction_keys),
197
+ _delete_keys,
198
+ on_error=lambda _err: None,
199
+ )
200
+
201
+ if consolidate is not None and consolidate_trigger is not None:
202
+ consolidation_stream = switch_map(
203
+ lambda _t: _coerce_node_input(consolidate(_snapshot_map(store))),
204
+ )(from_any(consolidate_trigger))
205
+ for_each(
206
+ consolidation_stream,
207
+ lambda ex: _apply_extraction(store, ex),
208
+ on_error=lambda _err: None,
209
+ )
210
+
211
+ compact = derived(
212
+ [store.data, context_node],
213
+ lambda deps, _a: _pack_compact(
214
+ deps[0].value if hasattr(deps[0], "value") else deps[0],
215
+ deps[1],
216
+ score,
217
+ cost,
218
+ budget,
219
+ ),
220
+ )
221
+ size = derived(
222
+ [store.data],
223
+ lambda deps, _a: len(deps[0].value if hasattr(deps[0], "value") else deps[0]),
224
+ initial=0,
225
+ )
226
+ compact.subscribe(lambda _msgs: None)
227
+ size.subscribe(lambda _msgs: None)
228
+
229
+ return DistillBundle(store=store, compact=compact, size=size)
230
+
231
+
232
+ def _coerce_node_input(value: Any) -> Node[Any]:
233
+ if isinstance(value, Node):
234
+ return value
235
+ if isinstance(value, AsyncIterable):
236
+ return from_any(value)
237
+ if isinstance(value, Awaitable):
238
+ return from_any(value)
239
+ if isinstance(value, Mapping):
240
+ return of(value)
241
+ if isinstance(value, (str, bytes, bytearray)):
242
+ return of(value)
243
+ if isinstance(value, Iterable):
244
+ return from_any(value)
245
+ return of(value)
246
+
247
+
248
+ def _compute_evictions(
249
+ get: Callable[[Node[Any]], Any],
250
+ store: ReactiveMapBundle,
251
+ evict: Callable[[str, Any], Any],
252
+ ) -> list[str]:
253
+ out: list[str] = []
254
+ current = get(store.data)
255
+ snapshot = current.value if hasattr(current, "value") else current
256
+ for key, mem in snapshot.items():
257
+ verdict = evict(key, mem)
258
+ if isinstance(verdict, Node):
259
+ if get(verdict) is True:
260
+ out.append(key)
261
+ continue
262
+ if isinstance(verdict, bool):
263
+ if verdict:
264
+ out.append(key)
265
+ continue
266
+ msg = "distill evict() must return bool or Node[bool] for reactive tracking"
267
+ raise TypeError(msg)
268
+ return out
269
+
270
+
271
+ def _pack_compact(
272
+ snapshot: Mapping[str, Any],
273
+ context: Any,
274
+ score: Callable[[Any, Any], float],
275
+ cost: Callable[[Any], float],
276
+ budget: float,
277
+ ) -> list[dict[str, Any]]:
278
+ ranked = [
279
+ {
280
+ "key": key,
281
+ "value": value,
282
+ "score": float(score(value, context)),
283
+ "cost": float(cost(value)),
284
+ }
285
+ for key, value in snapshot.items()
286
+ ]
287
+ ranked.sort(key=lambda row: row["score"], reverse=True)
288
+ packed: list[dict[str, Any]] = []
289
+ remaining = float(budget)
290
+ for row in ranked:
291
+ if row["cost"] <= remaining:
292
+ packed.append({"key": row["key"], "value": row["value"], "score": row["score"]})
293
+ remaining -= row["cost"]
294
+ return packed
295
+
296
+
297
+ __all__ = [
298
+ "DistillBundle",
299
+ "Extraction",
300
+ "VerifiableBundle",
301
+ "distill",
302
+ "verifiable",
303
+ ]
@@ -0,0 +1,133 @@
1
+ """Minimal 5-field cron parser and matcher (minute hour day-of-month month day-of-week).
2
+
3
+ Ported from graphrefly-ts extra/cron.ts for from_cron (roadmap 2.3).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from datetime import datetime
13
+
14
+
15
+ @dataclass
16
+ class CronSchedule:
17
+ """Parsed representation of a 5-field cron expression.
18
+
19
+ Produced by :func:`parse_cron`; consumed by :func:`matches_cron`.
20
+
21
+ Attributes:
22
+ minutes: Set of matching minute values (0–59).
23
+ hours: Set of matching hour values (0–23).
24
+ days_of_month: Set of matching day-of-month values (1–31).
25
+ months: Set of matching month values (1–12).
26
+ days_of_week: Set of matching day-of-week values (0–6, where 0 = Sunday).
27
+
28
+ Example:
29
+ ```python
30
+ from graphrefly.extra.cron import parse_cron
31
+ schedule = parse_cron("0 9 * * 1")
32
+ assert 0 in schedule.minutes
33
+ assert 9 in schedule.hours
34
+ ```
35
+ """
36
+
37
+ minutes: set[int] = field(default_factory=set)
38
+ hours: set[int] = field(default_factory=set)
39
+ days_of_month: set[int] = field(default_factory=set)
40
+ months: set[int] = field(default_factory=set)
41
+ days_of_week: set[int] = field(default_factory=set)
42
+
43
+
44
+ def _parse_field(field_str: str, min_val: int, max_val: int) -> set[int]:
45
+ result: set[int] = set()
46
+ for part in field_str.split(","):
47
+ pieces = part.split("/")
48
+ range_str = pieces[0]
49
+ step = int(pieces[1]) if len(pieces) > 1 else 1
50
+ if step < 1:
51
+ msg = f"Invalid cron step: {part}"
52
+ raise ValueError(msg)
53
+ if range_str == "*":
54
+ start, end = min_val, max_val
55
+ elif "-" in range_str:
56
+ a, b = range_str.split("-")
57
+ start, end = int(a), int(b)
58
+ else:
59
+ start = int(range_str)
60
+ end = start
61
+ if start < min_val or end > max_val:
62
+ msg = f"Cron field out of range: {field_str} ({min_val}-{max_val})"
63
+ raise ValueError(msg)
64
+ if start > end:
65
+ msg = f"Invalid cron range: {start}-{end} in {field_str}"
66
+ raise ValueError(msg)
67
+ for i in range(start, end + 1, step):
68
+ result.add(i)
69
+ return result
70
+
71
+
72
+ def parse_cron(expr: str) -> CronSchedule:
73
+ """Parse a standard 5-field cron expression into a :class:`CronSchedule`.
74
+
75
+ Supports ``*``, ranges (``1-5``), steps (``*/2``), and comma-separated lists.
76
+ Fields are: minute hour day-of-month month day-of-week.
77
+
78
+ Args:
79
+ expr: A cron expression string with exactly 5 whitespace-separated fields.
80
+
81
+ Returns:
82
+ A :class:`CronSchedule` with the parsed field sets.
83
+
84
+ Example:
85
+ ```python
86
+ from graphrefly.extra.cron import parse_cron
87
+ s = parse_cron("0 9 * * 1-5")
88
+ assert 9 in s.hours
89
+ assert 0 not in s.days_of_week # Sunday excluded
90
+ ```
91
+ """
92
+ parts = expr.strip().split()
93
+ if len(parts) != 5: # noqa: PLR2004
94
+ msg = f"Invalid cron: expected 5 fields, got {len(parts)}"
95
+ raise ValueError(msg)
96
+ return CronSchedule(
97
+ minutes=_parse_field(parts[0], 0, 59),
98
+ hours=_parse_field(parts[1], 0, 23),
99
+ days_of_month=_parse_field(parts[2], 1, 31),
100
+ months=_parse_field(parts[3], 1, 12),
101
+ days_of_week=_parse_field(parts[4], 0, 6),
102
+ )
103
+
104
+
105
+ def matches_cron(schedule: CronSchedule, dt: datetime) -> bool:
106
+ """Return True when *dt* satisfies every field of *schedule*.
107
+
108
+ Args:
109
+ schedule: A :class:`CronSchedule` produced by :func:`parse_cron`.
110
+ dt: A :class:`datetime.datetime` to test.
111
+
112
+ Returns:
113
+ ``True`` if minute, hour, day-of-month, month, and day-of-week all match.
114
+
115
+ Example:
116
+ ```python
117
+ from datetime import datetime
118
+ from graphrefly.extra.cron import parse_cron, matches_cron
119
+ s = parse_cron("30 8 * * *")
120
+ dt = datetime(2025, 1, 1, 8, 30)
121
+ assert matches_cron(s, dt)
122
+ ```
123
+ """
124
+ return (
125
+ dt.minute in schedule.minutes
126
+ and dt.hour in schedule.hours
127
+ and dt.day in schedule.days_of_month
128
+ and dt.month in schedule.months
129
+ and dt.isoweekday() % 7 in schedule.days_of_week
130
+ )
131
+
132
+
133
+ __all__ = ["CronSchedule", "matches_cron", "parse_cron"]