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,277 @@
1
+ """Actor context, capability guards, and policy builder (roadmap Phase 1.5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from typing import Any, Literal, TypedDict
7
+
8
+ type GuardAction = str
9
+ """Known actions: ``"write"``, ``"signal"``, ``"observe"``.
10
+
11
+ Open type (plain ``str``) so callers can define domain-specific actions
12
+ (e.g. ``"admin"``, ``"delete"``) or use the wildcard ``"*"`` in
13
+ :func:`policy` rules. Aligned with the TS ``(string & {})`` pattern.
14
+ """
15
+
16
+ #: ``(actor, action) -> bool`` — when ``False``, APIs raise :exc:`GuardDenied`.
17
+ GuardFn = Callable[["Actor", GuardAction], bool]
18
+
19
+
20
+ class Actor(TypedDict, total=False):
21
+ """Who is acting. Extra string keys are allowed at runtime (ABAC claims)."""
22
+
23
+ type: str
24
+ id: str
25
+
26
+
27
+ def system_actor() -> Actor:
28
+ """Default actor for mutations that do not pass an explicit context."""
29
+ return {"type": "system", "id": ""}
30
+
31
+
32
+ def normalize_actor(actor: Mapping[str, Any] | Actor | None) -> dict[str, Any]:
33
+ """Merge with :func:`system_actor` defaults (missing ``type`` / ``id`` filled)."""
34
+ base: dict[str, Any] = {"type": "system", "id": ""}
35
+ if actor is not None:
36
+ base.update(dict(actor))
37
+ if not base.get("type"):
38
+ base["type"] = "system"
39
+ if "id" not in base:
40
+ base["id"] = ""
41
+ return base
42
+
43
+
44
+ class GuardDenied(Exception):
45
+ """Raised when a node's guard rejects an action."""
46
+
47
+ __slots__ = ("action", "actor", "node")
48
+
49
+ def __init__(
50
+ self,
51
+ actor: Mapping[str, Any],
52
+ node: str,
53
+ action: GuardAction,
54
+ ) -> None:
55
+ self.actor = dict(actor)
56
+ self.node = node
57
+ self.action = action
58
+ super().__init__(f"guard denied {action!r} on node {node!r} for actor {self.actor!r}")
59
+
60
+
61
+ def _normalize_actions(
62
+ action: GuardAction | list[GuardAction] | tuple[GuardAction, ...],
63
+ ) -> frozenset[str]:
64
+ """Accept a single action string or a sequence and return a frozen set."""
65
+ if isinstance(action, str):
66
+ return frozenset((action,))
67
+ return frozenset(action)
68
+
69
+
70
+ def _matches_actions(actions: frozenset[str], guard_action: GuardAction) -> bool:
71
+ """Check membership with ``"*"`` wildcard support (aligned with TS)."""
72
+ return guard_action in actions or "*" in actions
73
+
74
+
75
+ def _policy_rule_result(
76
+ *,
77
+ actions: frozenset[str],
78
+ where: Callable[[Actor], bool] | None,
79
+ actor: Mapping[str, Any],
80
+ guard_action: GuardAction,
81
+ ) -> bool:
82
+ return _matches_actions(actions, guard_action) and (where is None or where(actor)) # type: ignore[arg-type]
83
+
84
+
85
+ def policy(
86
+ build: Callable[
87
+ [
88
+ Callable[..., None],
89
+ Callable[..., None],
90
+ ],
91
+ Any,
92
+ ],
93
+ ) -> GuardFn:
94
+ """Build a guard function from declarative allow/deny rules.
95
+
96
+ Precedence: for a given ``(actor, action)``, any matching ``deny`` wins
97
+ (returns ``False``). Otherwise, any matching ``allow`` returns ``True``.
98
+ If no rule matches, the result is ``False``.
99
+
100
+ Args:
101
+ build: Callable invoked with ``(allow, deny)`` accumulators. Each accumulator
102
+ accepts an ``action`` (str or list of str, supports ``"*"`` wildcard) and
103
+ an optional ``where`` predicate ``(actor) -> bool``.
104
+
105
+ Returns:
106
+ A :data:`GuardFn` ``(actor, action) -> bool``.
107
+
108
+ Example:
109
+ ```python
110
+ from graphrefly import state, policy
111
+ g = policy(lambda allow, deny: [
112
+ allow("write", where=lambda a: a.get("role") == "admin"),
113
+ deny("write", where=lambda a: a.get("type") == "llm"),
114
+ ])
115
+ x = state(0, guard=g)
116
+ ```
117
+ """
118
+ Rule = tuple[Literal["allow", "deny"], frozenset[str], Callable[[Actor], bool] | None]
119
+ rules: list[Rule] = []
120
+
121
+ def allow(
122
+ action: GuardAction | list[GuardAction] | tuple[GuardAction, ...],
123
+ *,
124
+ where: Callable[[Actor], bool] | None = None,
125
+ ) -> None:
126
+ rules.append(("allow", _normalize_actions(action), where))
127
+
128
+ def deny(
129
+ action: GuardAction | list[GuardAction] | tuple[GuardAction, ...],
130
+ *,
131
+ where: Callable[[Actor], bool] | None = None,
132
+ ) -> None:
133
+ rules.append(("deny", _normalize_actions(action), where))
134
+
135
+ build(allow, deny)
136
+
137
+ def guard(actor: Actor, guard_action: GuardAction) -> bool:
138
+ a = normalize_actor(actor)
139
+ denied = False
140
+ allowed = False
141
+ for kind, acts, where in rules:
142
+ matched = _policy_rule_result(
143
+ actions=acts,
144
+ where=where,
145
+ actor=a,
146
+ guard_action=guard_action,
147
+ )
148
+ if not matched:
149
+ continue
150
+ if kind == "deny":
151
+ denied = True
152
+ else:
153
+ allowed = True
154
+ if denied:
155
+ return False
156
+ return allowed
157
+
158
+ return guard
159
+
160
+
161
+ def policy_from_rules(rules: list[dict[str, Any]]) -> GuardFn:
162
+ """Rebuild a declarative guard from persisted rule data.
163
+
164
+ Rule schema:
165
+ - ``effect``: ``"allow"`` or ``"deny"``
166
+ - ``action``: str or list[str]
167
+ - ``actorType``: optional str or list[str]
168
+ - ``actorId``: optional str or list[str]
169
+ - ``claims``: optional dict[str, Any] exact-match constraints
170
+ """
171
+
172
+ def build(
173
+ allow: Callable[..., None],
174
+ deny: Callable[..., None],
175
+ ) -> None:
176
+ for rule in rules:
177
+ effect = str(rule.get("effect", "allow")).lower()
178
+ if effect not in ("allow", "deny"):
179
+ raise ValueError(f"policy_from_rules unknown effect {effect!r}")
180
+ action_value = rule.get("action", "write")
181
+ actor_type_raw = rule.get("actorType")
182
+ actor_id_raw = rule.get("actorId")
183
+ claims = rule.get("claims")
184
+ actor_types = (
185
+ None
186
+ if actor_type_raw is None
187
+ else set(actor_type_raw if isinstance(actor_type_raw, list) else [actor_type_raw])
188
+ )
189
+ actor_ids = (
190
+ None
191
+ if actor_id_raw is None
192
+ else set(actor_id_raw if isinstance(actor_id_raw, list) else [actor_id_raw])
193
+ )
194
+ claim_items = list(claims.items()) if isinstance(claims, dict) else []
195
+
196
+ def where(
197
+ actor: Actor,
198
+ _types: set[Any] | None = actor_types,
199
+ _ids: set[Any] | None = actor_ids,
200
+ _claims: list[tuple[Any, Any]] = claim_items,
201
+ ) -> bool:
202
+ if _types is not None and actor.get("type") not in _types:
203
+ return False
204
+ if _ids is not None and actor.get("id", "") not in _ids:
205
+ return False
206
+ return all(actor.get(str(k)) == v for k, v in _claims)
207
+
208
+ if effect == "deny":
209
+ deny(action_value, where=where)
210
+ else:
211
+ allow(action_value, where=where)
212
+
213
+ return policy(build)
214
+
215
+
216
+ def compose_guards(*guards: GuardFn | None) -> GuardFn:
217
+ """Compose multiple guard functions with AND logic; ``None`` entries are skipped.
218
+
219
+ Args:
220
+ *guards: Any number of :data:`GuardFn` callables or ``None``. ``None`` values
221
+ are silently filtered out.
222
+
223
+ Returns:
224
+ A :data:`GuardFn` that returns ``True`` only when every non-None guard
225
+ approves the ``(actor, action)`` pair.
226
+
227
+ Example:
228
+ ```python
229
+ from graphrefly.core.guard import compose_guards, policy
230
+ g1 = policy(lambda allow, _: allow("write"))
231
+ g2 = policy(lambda allow, _: allow("observe"))
232
+ combined = compose_guards(g1, g2)
233
+ ```
234
+ """
235
+ gs = [g for g in guards if g is not None]
236
+
237
+ def composed(actor: Actor, action: GuardAction) -> bool:
238
+ return all(g(actor, action) for g in gs)
239
+
240
+ return composed
241
+
242
+
243
+ _STANDARD_WRITE_TYPES = ("human", "llm", "wallet", "system")
244
+
245
+
246
+ def access_hint_for_guard(guard: GuardFn) -> str:
247
+ """Best-effort ``meta.access`` string when a guard is present (roadmap 1.5)."""
248
+ allowed = [t for t in _STANDARD_WRITE_TYPES if guard({"type": t, "id": ""}, "write")]
249
+ if not allowed:
250
+ return "restricted"
251
+ if "human" in allowed and "llm" in allowed and set(allowed) <= {"human", "llm", "system"}:
252
+ return "both"
253
+ if len(allowed) == 1:
254
+ return allowed[0]
255
+ return "+".join(allowed)
256
+
257
+
258
+ def record_mutation(actor: Mapping[str, Any]) -> dict[str, Any]:
259
+ """Snapshot for :attr:`~graphrefly.core.node.NodeImpl.last_mutation`."""
260
+ from graphrefly.core.clock import wall_clock_ns
261
+
262
+ return {"actor": dict(normalize_actor(actor)), "timestamp_ns": wall_clock_ns()}
263
+
264
+
265
+ __all__ = [
266
+ "Actor",
267
+ "GuardAction",
268
+ "GuardDenied",
269
+ "GuardFn",
270
+ "access_hint_for_guard",
271
+ "compose_guards",
272
+ "normalize_actor",
273
+ "policy",
274
+ "policy_from_rules",
275
+ "record_mutation",
276
+ "system_actor",
277
+ ]
@@ -0,0 +1,149 @@
1
+ """Meta companion stores — graphrefly-ts ``src/core/meta.ts``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import suppress
6
+ from typing import TYPE_CHECKING, Any, Protocol
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Mapping
10
+
11
+ from graphrefly.core.dynamic_node import DynamicNodeImpl
12
+ from graphrefly.core.guard import access_hint_for_guard
13
+ from graphrefly.core.node import NodeImpl # noqa: TC001 — runtime type for describe_node
14
+ from graphrefly.core.versioning import is_v1
15
+
16
+ __all__ = ["describe_node", "meta_snapshot"]
17
+
18
+
19
+ class _MetaNode(Protocol):
20
+ """Parent node: read-only ``meta`` mapping (e.g. ``MappingProxyType``)."""
21
+
22
+ @property
23
+ def meta(self) -> Mapping[str, Any]: ...
24
+
25
+ def get(self) -> Any: ...
26
+
27
+
28
+ def meta_snapshot(node: _MetaNode) -> dict[str, Any]:
29
+ """Merge current meta field values into a plain dict for describe-style JSON.
30
+
31
+ Values come from each companion node's ``get()`` (last settled cache). If a meta
32
+ field is dirty (``DIRTY`` received, ``DATA`` pending), the snapshot holds the
33
+ previous value — check ``node.meta[key].status`` when freshness matters.
34
+ Keys whose companion ``get()`` raises are omitted so describe tooling keeps other
35
+ fields intact.
36
+
37
+ Args:
38
+ node: Any object with a ``meta`` mapping of ``str -> Node`` and a ``get()`` method.
39
+
40
+ Returns:
41
+ A plain ``dict`` mapping each meta key to its last settled value.
42
+
43
+ Example:
44
+ ```python
45
+ from graphrefly import state
46
+ from graphrefly.core.meta import meta_snapshot
47
+ x = state(0, meta={"version": 1})
48
+ snap = meta_snapshot(x)
49
+ # snap == {"version": 1}
50
+ ```
51
+ """
52
+ out: dict[str, Any] = {}
53
+ for key, child in node.meta.items():
54
+ with suppress(Exception):
55
+ out[key] = child.get()
56
+ return out
57
+
58
+
59
+ def _infer_describe_type(n: NodeImpl[Any]) -> str:
60
+ """Best-effort ``type`` for GRAPHREFLY-SPEC describe node shape (§3.6, Appendix B)."""
61
+ if n._describe_kind is not None:
62
+ return n._describe_kind
63
+ if not n._has_deps:
64
+ return "state" if n._fn is None else "producer"
65
+ if n._fn is None:
66
+ return "derived"
67
+ if n._manual_emit_used:
68
+ return "operator"
69
+ return "derived"
70
+
71
+
72
+ def describe_node(n: NodeImpl[Any] | DynamicNodeImpl[Any]) -> dict[str, Any]:
73
+ """Build a single-node slice of ``Graph.describe()`` JSON (structure + ``meta`` snapshot).
74
+
75
+ The ``meta`` field uses :func:`meta_snapshot` (plain values), matching the
76
+ spec's describe shape (GRAPHREFLY-SPEC Appendix B). ``type`` is inferred from
77
+ factory configuration, optional ``describe_kind`` in node options, and the last
78
+ ``_manual_emit_used`` hint (operator vs derived). Sugar constructors
79
+ (``effect``, ``producer``, ``derived``) set ``describe_kind`` automatically.
80
+
81
+ Args:
82
+ n: A :class:`~graphrefly.core.node.NodeImpl` or
83
+ :class:`~graphrefly.core.dynamic_node.DynamicNodeImpl` to describe.
84
+
85
+ Returns:
86
+ A ``dict`` with keys ``type``, ``status``, ``deps``, ``meta``, and optionally
87
+ ``name`` and ``value``.
88
+
89
+ Example:
90
+ ```python
91
+ from graphrefly import state
92
+ from graphrefly.core.meta import describe_node
93
+ x = state(42, name="x")
94
+ d = describe_node(x)
95
+ assert d["type"] == "state"
96
+ assert d["value"] == 42
97
+ ```
98
+ """
99
+ if isinstance(n, DynamicNodeImpl):
100
+ out: dict[str, Any] = {
101
+ "type": n._describe_kind or "derived",
102
+ "status": n.status,
103
+ "deps": [],
104
+ "meta": meta_snapshot(n),
105
+ }
106
+ if n.name is not None:
107
+ out["name"] = n.name
108
+ with suppress(Exception):
109
+ out["value"] = n.get()
110
+ g = n._guard
111
+ if g is not None:
112
+ meta = dict(out.get("meta") or {})
113
+ if "access" not in meta:
114
+ meta["access"] = access_hint_for_guard(g)
115
+ out["meta"] = meta
116
+ # Versioning (GRAPHREFLY-SPEC §7)
117
+ if hasattr(n, "v") and n.v is not None:
118
+ out["v"] = _versioning_dict(n.v)
119
+ return out
120
+
121
+ out = {
122
+ "type": _infer_describe_type(n),
123
+ "status": n.status,
124
+ "deps": [d.name or "" for d in n._deps],
125
+ "meta": meta_snapshot(n),
126
+ }
127
+ if n.name is not None:
128
+ out["name"] = n.name
129
+ with suppress(Exception):
130
+ out["value"] = n.get()
131
+ g = n._guard
132
+ if g is not None:
133
+ meta = dict(out.get("meta") or {})
134
+ if "access" not in meta:
135
+ meta["access"] = access_hint_for_guard(g)
136
+ out["meta"] = meta
137
+ # Versioning (GRAPHREFLY-SPEC §7)
138
+ if n.v is not None:
139
+ out["v"] = _versioning_dict(n.v)
140
+ return out
141
+
142
+
143
+ def _versioning_dict(v: Any) -> dict[str, Any]:
144
+ """Convert versioning info to a plain dict for JSON serialization."""
145
+ d: dict[str, Any] = {"id": v.id, "version": v.version}
146
+ if is_v1(v):
147
+ d["cid"] = v.cid
148
+ d["prev"] = v.prev
149
+ return d