reactifact 0.6.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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- reactifact-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""`reactifact scenario` — run `reactifact.testing` scenarios.
|
|
2
|
+
|
|
3
|
+
A separate track from `pytest`: scenarios are `@scenario`-decorated functions
|
|
4
|
+
(usually wrapping `ScenarioLab.run()`) living in ordinary modules, imported by
|
|
5
|
+
dotted path exactly like `reactifact graph <module:Attr>` resolves agents — no
|
|
6
|
+
`test_*.py` naming, no pytest collection, so a plain `pytest` run never needs a
|
|
7
|
+
model key or a network connection. Point this at one or more modules and it
|
|
8
|
+
imports them, runs whatever they registered, and reports PASS/FAIL/SKIP.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import asyncio
|
|
15
|
+
import inspect
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from ..testing.exceptions import ScenarioSkip
|
|
20
|
+
from ..testing.record import MODE_ENV_VAR
|
|
21
|
+
from ..testing.registry import ScenarioCase, collect
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _run_one(case: ScenarioCase) -> tuple[str, str]:
|
|
25
|
+
"""Returns `(status, detail)` — status is one of PASS/FAIL/ERROR/SKIP."""
|
|
26
|
+
try:
|
|
27
|
+
result = case.func()
|
|
28
|
+
if inspect.isawaitable(result):
|
|
29
|
+
await result
|
|
30
|
+
except ScenarioSkip as exc:
|
|
31
|
+
return "SKIP", str(exc)
|
|
32
|
+
except AssertionError as exc:
|
|
33
|
+
return "FAIL", str(exc)
|
|
34
|
+
except Exception as exc: # noqa: BLE001 — report, don't crash the run
|
|
35
|
+
return "ERROR", f"{type(exc).__name__}: {exc}"
|
|
36
|
+
return "PASS", ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_scenario(args: argparse.Namespace) -> int:
|
|
40
|
+
if args.mode is not None:
|
|
41
|
+
os.environ[MODE_ENV_VAR] = args.mode
|
|
42
|
+
|
|
43
|
+
cases = collect(args.modules)
|
|
44
|
+
if args.filter:
|
|
45
|
+
cases = [c for c in cases if args.filter in c.name]
|
|
46
|
+
if not cases:
|
|
47
|
+
print("no scenarios found (check the module path and -k filter)")
|
|
48
|
+
return 1
|
|
49
|
+
|
|
50
|
+
counts = {"PASS": 0, "FAIL": 0, "ERROR": 0, "SKIP": 0}
|
|
51
|
+
for case in cases:
|
|
52
|
+
started = time.monotonic()
|
|
53
|
+
status, detail = asyncio.run(_run_one(case))
|
|
54
|
+
elapsed = time.monotonic() - started
|
|
55
|
+
counts[status] += 1
|
|
56
|
+
line = f"{status:<5} {case.name} ({elapsed:.2f}s)"
|
|
57
|
+
print(line)
|
|
58
|
+
if detail:
|
|
59
|
+
print(f" {detail}")
|
|
60
|
+
|
|
61
|
+
total = len(cases)
|
|
62
|
+
print(
|
|
63
|
+
f"\n{total} scenario(s): {counts['PASS']} passed, {counts['FAIL']} failed, "
|
|
64
|
+
f"{counts['ERROR']} errored, {counts['SKIP']} skipped"
|
|
65
|
+
)
|
|
66
|
+
return 1 if counts["FAIL"] or counts["ERROR"] else 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
70
|
+
p_scenario = sub.add_parser(
|
|
71
|
+
"scenario", help="run reactifact.testing scenarios (separate from pytest)"
|
|
72
|
+
)
|
|
73
|
+
p_scenario.add_argument(
|
|
74
|
+
"modules",
|
|
75
|
+
nargs="+",
|
|
76
|
+
help='dotted module path(s) to import, e.g. "examples.repair.scenarios"',
|
|
77
|
+
)
|
|
78
|
+
p_scenario.add_argument(
|
|
79
|
+
"-k",
|
|
80
|
+
"--filter",
|
|
81
|
+
default=None,
|
|
82
|
+
help="only run scenarios whose name contains this substring",
|
|
83
|
+
)
|
|
84
|
+
p_scenario.add_argument(
|
|
85
|
+
"--mode",
|
|
86
|
+
choices=["live", "record", "replay"],
|
|
87
|
+
default=None,
|
|
88
|
+
help=(
|
|
89
|
+
"sets REACTIFACT_SCENARIO_MODE for scenarios built with "
|
|
90
|
+
"reactifact.testing.mode_from_env() — most scenarios default to "
|
|
91
|
+
"'live' or opt out entirely without it"
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
p_scenario.set_defaults(func=cmd_scenario)
|
reactifact/cli/trace.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""`reactifact trace` — run diagram from a trace store (SQLite)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cmd_trace(args: argparse.Namespace) -> int:
|
|
9
|
+
import asyncio
|
|
10
|
+
import sqlite3
|
|
11
|
+
|
|
12
|
+
from ..tracing import TraceStore
|
|
13
|
+
from ..viz import trace_to_mermaid
|
|
14
|
+
|
|
15
|
+
store = TraceStore(args.path)
|
|
16
|
+
|
|
17
|
+
async def _run() -> int:
|
|
18
|
+
trace_id = args.run_id
|
|
19
|
+
try:
|
|
20
|
+
if trace_id is None:
|
|
21
|
+
result = await store.query(limit=1)
|
|
22
|
+
if not result["items"]:
|
|
23
|
+
print("no traces found")
|
|
24
|
+
return 1
|
|
25
|
+
trace_id = result["items"][0]["id"]
|
|
26
|
+
trace = await store.get(trace_id)
|
|
27
|
+
except sqlite3.OperationalError:
|
|
28
|
+
print(f"no trace store found at {args.path!r}")
|
|
29
|
+
return 1
|
|
30
|
+
if trace is None:
|
|
31
|
+
print(f"trace {trace_id!r} not found")
|
|
32
|
+
return 1
|
|
33
|
+
print(trace_to_mermaid(trace))
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
return asyncio.run(_run())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
40
|
+
p_trace = sub.add_parser("trace", help="run diagram from a trace store")
|
|
41
|
+
p_trace.add_argument("path", help="trace SQLite db")
|
|
42
|
+
p_trace.add_argument(
|
|
43
|
+
"run_id", nargs="?", default=None, help="run id (default: latest)"
|
|
44
|
+
)
|
|
45
|
+
p_trace.set_defaults(func=cmd_trace)
|
reactifact/commit.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .patches import Operation, operation_from_dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Read:
|
|
13
|
+
"""A record of a consumed artifact: the runtime builds links from consumes.
|
|
14
|
+
|
|
15
|
+
Records the fact that the artifact was read at a specific revision
|
|
16
|
+
(git-like ancestor).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
artifact_id: str
|
|
20
|
+
version: int
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict[str, Any]:
|
|
23
|
+
return {"artifact_id": self.artifact_id, "version": self.version}
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, d: dict[str, Any]) -> Read:
|
|
27
|
+
return cls(artifact_id=d["artifact_id"], version=d["version"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Write:
|
|
32
|
+
"""A record of an artifact produced/changed by a single commit."""
|
|
33
|
+
|
|
34
|
+
artifact_id: str
|
|
35
|
+
version: int
|
|
36
|
+
op_type: str # create | update | delete
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"artifact_id": self.artifact_id,
|
|
41
|
+
"version": self.version,
|
|
42
|
+
"op_type": self.op_type,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, d: dict[str, Any]) -> Write:
|
|
47
|
+
return cls(
|
|
48
|
+
artifact_id=d["artifact_id"],
|
|
49
|
+
version=d["version"],
|
|
50
|
+
op_type=d["op_type"],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Commit:
|
|
56
|
+
"""A record of one applied patch.
|
|
57
|
+
|
|
58
|
+
Commits form a git-like chain: each commit knows its parent, was executed
|
|
59
|
+
against a specific Context version and carries a reads/writes trace.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
author: str
|
|
63
|
+
message: str
|
|
64
|
+
operations: list[Operation]
|
|
65
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
66
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
67
|
+
parent_id: str | None = None
|
|
68
|
+
context_version: int | None = None
|
|
69
|
+
reads: list[Read] = field(default_factory=list)
|
|
70
|
+
writes: list[Write] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> dict[str, Any]:
|
|
73
|
+
return {
|
|
74
|
+
"id": self.id,
|
|
75
|
+
"author": self.author,
|
|
76
|
+
"message": self.message,
|
|
77
|
+
"timestamp": self.timestamp.isoformat(),
|
|
78
|
+
"operations": [op.to_dict() for op in self.operations],
|
|
79
|
+
"parent_id": self.parent_id,
|
|
80
|
+
"context_version": self.context_version,
|
|
81
|
+
"reads": [r.to_dict() for r in self.reads],
|
|
82
|
+
"writes": [w.to_dict() for w in self.writes],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_dict(cls, d: dict[str, Any]) -> Commit:
|
|
87
|
+
return cls(
|
|
88
|
+
author=d["author"],
|
|
89
|
+
message=d["message"],
|
|
90
|
+
operations=[operation_from_dict(op) for op in d["operations"]],
|
|
91
|
+
id=d["id"],
|
|
92
|
+
timestamp=datetime.fromisoformat(d["timestamp"]),
|
|
93
|
+
parent_id=d.get("parent_id"),
|
|
94
|
+
context_version=d.get("context_version"),
|
|
95
|
+
reads=[Read.from_dict(r) for r in d.get("reads", [])],
|
|
96
|
+
writes=[Write.from_dict(w) for w in d.get("writes", [])],
|
|
97
|
+
)
|
reactifact/commit_log.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""reactifact.commit_log — the git-like commit chain (§12).
|
|
2
|
+
|
|
3
|
+
Extracted out of `Context`: `CommitLog` owns the ordered list of applied
|
|
4
|
+
`Commit`s, the current version/head, and the pure "replay commits into a
|
|
5
|
+
state snapshot" logic (`replay_state`/`replay_relations`) that `checkout`,
|
|
6
|
+
`diff`, and staleness detection all build on. It knows nothing about the
|
|
7
|
+
live artifact/relation stores — `Context` still owns those and asks the log
|
|
8
|
+
to replay itself when it needs a past or reconstructed state.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .commit import Commit
|
|
17
|
+
from .patches import Create, Delete, Link, Relation, Unlink, Update
|
|
18
|
+
from .relations import RelationKey
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CommitLog:
|
|
22
|
+
"""The applied-commit chain: append, replay, roll back (§12).
|
|
23
|
+
|
|
24
|
+
Alongside the commit list, maintains two incrementally-updated indices so
|
|
25
|
+
`producing_commit`/`dependents_of` stay O(1)/O(dependents) instead of
|
|
26
|
+
scanning every commit on every call (the two hot paths behind
|
|
27
|
+
`Context.stale_artifacts()`/`_dependents_of()`):
|
|
28
|
+
|
|
29
|
+
- `_last_write_commit`: artifact_id → the commit that last wrote it.
|
|
30
|
+
- `_dependents`: artifact_id → the set of artifact ids whose *current*
|
|
31
|
+
producing commit reads it (i.e. "who depends on me right now").
|
|
32
|
+
`_producing_read_ids` tracks each artifact's current read-set so a
|
|
33
|
+
later rewrite can remove its stale edges before adding the new ones.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__slots__ = (
|
|
37
|
+
"_commits",
|
|
38
|
+
"_version",
|
|
39
|
+
"_head_id",
|
|
40
|
+
"_last_write_commit",
|
|
41
|
+
"_dependents",
|
|
42
|
+
"_producing_read_ids",
|
|
43
|
+
"_dict_cache",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._commits: list[Commit] = []
|
|
48
|
+
self._version: int = 0
|
|
49
|
+
self._head_id: str | None = None
|
|
50
|
+
self._last_write_commit: dict[str, Commit] = {}
|
|
51
|
+
self._dependents: dict[str, set[str]] = {}
|
|
52
|
+
self._producing_read_ids: dict[str, set[str]] = {}
|
|
53
|
+
# Memoized Commit.to_dict(), keyed by commit id: a commit is immutable
|
|
54
|
+
# once appended (writes/reads/operations never change afterward), so
|
|
55
|
+
# this can never go stale — see `to_dict()`.
|
|
56
|
+
self._dict_cache: dict[str, dict[str, Any]] = {}
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def version(self) -> int:
|
|
60
|
+
"""Current version (number of applied commits)."""
|
|
61
|
+
return self._version
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def head_id(self) -> str | None:
|
|
65
|
+
"""Id of the last commit (HEAD)."""
|
|
66
|
+
return self._head_id
|
|
67
|
+
|
|
68
|
+
def append(self, commit: Commit) -> None:
|
|
69
|
+
"""Fills in parent/version and moves head (was `Context.log_commit`)."""
|
|
70
|
+
commit.parent_id = self._head_id
|
|
71
|
+
commit.context_version = self._version + 1
|
|
72
|
+
self._commits.append(commit)
|
|
73
|
+
self._head_id = commit.id
|
|
74
|
+
self._version += 1
|
|
75
|
+
self._index_commit(commit)
|
|
76
|
+
|
|
77
|
+
def _index_commit(self, commit: Commit) -> None:
|
|
78
|
+
"""Updates `_last_write_commit`/`_dependents` for one appended commit."""
|
|
79
|
+
for write in commit.writes:
|
|
80
|
+
aid = write.artifact_id
|
|
81
|
+
old_sources = self._producing_read_ids.get(aid)
|
|
82
|
+
if old_sources:
|
|
83
|
+
for src in old_sources:
|
|
84
|
+
deps = self._dependents.get(src)
|
|
85
|
+
if deps is not None:
|
|
86
|
+
deps.discard(aid)
|
|
87
|
+
if not deps:
|
|
88
|
+
del self._dependents[src]
|
|
89
|
+
self._last_write_commit[aid] = commit
|
|
90
|
+
new_sources = {r.artifact_id for r in commit.reads}
|
|
91
|
+
self._producing_read_ids[aid] = new_sources
|
|
92
|
+
for src in new_sources:
|
|
93
|
+
self._dependents.setdefault(src, set()).add(aid)
|
|
94
|
+
|
|
95
|
+
def _rebuild_indices(self) -> None:
|
|
96
|
+
"""Full rebuild from `_commits` — used after a bulk rewrite (truncate,
|
|
97
|
+
copy, deserialize) where per-commit incremental updates don't apply."""
|
|
98
|
+
self._last_write_commit = {}
|
|
99
|
+
self._dependents = {}
|
|
100
|
+
self._producing_read_ids = {}
|
|
101
|
+
for commit in self._commits:
|
|
102
|
+
self._index_commit(commit)
|
|
103
|
+
|
|
104
|
+
def history(self) -> list[Commit]:
|
|
105
|
+
"""Ordered chain of commits from the oldest to head."""
|
|
106
|
+
return list(self._commits)
|
|
107
|
+
|
|
108
|
+
def __len__(self) -> int:
|
|
109
|
+
return len(self._commits)
|
|
110
|
+
|
|
111
|
+
def commits_from(self, index: int) -> list[Commit]:
|
|
112
|
+
"""Commits at `index` and after (used by `checkout` to find what a
|
|
113
|
+
rollback would undo)."""
|
|
114
|
+
return self._commits[index:]
|
|
115
|
+
|
|
116
|
+
def commits_upto(self, upto_version: int) -> list[Commit]:
|
|
117
|
+
"""Commits before `upto_version` (used by the replay methods)."""
|
|
118
|
+
return self._commits[:upto_version]
|
|
119
|
+
|
|
120
|
+
def producing_commit(self, artifact_id: str) -> Commit | None:
|
|
121
|
+
"""The last commit that wrote the artifact (create or update)."""
|
|
122
|
+
return self._last_write_commit.get(artifact_id)
|
|
123
|
+
|
|
124
|
+
def dependents_of(self, artifact_id: str) -> set[str]:
|
|
125
|
+
"""Artifact ids whose current producing commit reads `artifact_id`.
|
|
126
|
+
|
|
127
|
+
Structural only (ignores versions) — the caller still checks whether
|
|
128
|
+
the dependency is actually stale right now. Scoped to this artifact's
|
|
129
|
+
real dependents, not every artifact in the context.
|
|
130
|
+
"""
|
|
131
|
+
return set(self._dependents.get(artifact_id, ()))
|
|
132
|
+
|
|
133
|
+
def truncate(self, version: int) -> None:
|
|
134
|
+
"""Rolls the log back to `version`: drops later commits, moves head.
|
|
135
|
+
|
|
136
|
+
`version=0` means "before any commit" (no head).
|
|
137
|
+
"""
|
|
138
|
+
if version == 0:
|
|
139
|
+
self._head_id = None
|
|
140
|
+
else:
|
|
141
|
+
self._head_id = self._commits[version - 1].id
|
|
142
|
+
del self._commits[version:]
|
|
143
|
+
self._version = version
|
|
144
|
+
self._rebuild_indices()
|
|
145
|
+
live_ids = {c.id for c in self._commits}
|
|
146
|
+
self._dict_cache = {
|
|
147
|
+
cid: d for cid, d in self._dict_cache.items() if cid in live_ids
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def replay_state(self, upto_version: int) -> dict[str, Any]:
|
|
151
|
+
"""Replays the artifact state by applying commits up to and including
|
|
152
|
+
the version."""
|
|
153
|
+
state: dict[str, Any] = {}
|
|
154
|
+
for commit in self.commits_upto(upto_version):
|
|
155
|
+
for op in commit.operations:
|
|
156
|
+
if isinstance(op, Create) and op.artifact_id is not None:
|
|
157
|
+
state[op.artifact_id] = op.data.model_copy(deep=True)
|
|
158
|
+
elif isinstance(op, Update):
|
|
159
|
+
state[op.artifact_id] = op.new_data.model_copy(deep=True)
|
|
160
|
+
elif isinstance(op, Delete):
|
|
161
|
+
state.pop(op.artifact_id, None)
|
|
162
|
+
return state
|
|
163
|
+
|
|
164
|
+
def replay_relations(self, upto_version: int) -> dict[RelationKey, Relation]:
|
|
165
|
+
"""Replays the link graph from commits up to and including the version."""
|
|
166
|
+
relations: dict[RelationKey, Relation] = {}
|
|
167
|
+
for commit in self.commits_upto(upto_version):
|
|
168
|
+
for op in commit.operations:
|
|
169
|
+
if isinstance(op, Link):
|
|
170
|
+
key = (op.artifact_id, op.relation, op.target_id)
|
|
171
|
+
relations[key] = Relation(*key)
|
|
172
|
+
elif isinstance(op, Unlink):
|
|
173
|
+
for key in list(relations.keys()):
|
|
174
|
+
if key[0] != op.artifact_id:
|
|
175
|
+
continue
|
|
176
|
+
if op.relation is not None and key[1] != op.relation:
|
|
177
|
+
continue
|
|
178
|
+
if op.target_id is not None and key[2] != op.target_id:
|
|
179
|
+
continue
|
|
180
|
+
del relations[key]
|
|
181
|
+
return relations
|
|
182
|
+
|
|
183
|
+
def copy(self) -> CommitLog:
|
|
184
|
+
clone = CommitLog()
|
|
185
|
+
clone._commits = copy.deepcopy(self._commits)
|
|
186
|
+
clone._version = self._version
|
|
187
|
+
clone._head_id = self._head_id
|
|
188
|
+
# Deep-copied commits are new objects — re-derive the indices instead
|
|
189
|
+
# of copying dicts that would still point at the originals.
|
|
190
|
+
clone._rebuild_indices()
|
|
191
|
+
# A commit's serialized form only depends on its (unchanged-by-copy)
|
|
192
|
+
# field values, keyed by its (unchanged-by-copy) id — safe to carry
|
|
193
|
+
# the cache over instead of re-serializing on the clone's first save.
|
|
194
|
+
clone._dict_cache = dict(self._dict_cache)
|
|
195
|
+
return clone
|
|
196
|
+
|
|
197
|
+
def to_dict(self) -> list[dict[str, Any]]:
|
|
198
|
+
"""Serializes the commit chain, memoized per commit id.
|
|
199
|
+
|
|
200
|
+
A commit is immutable once appended (`writes` is filled in before
|
|
201
|
+
`Context.log_commit()` and never touched again), so re-serializing an
|
|
202
|
+
already-serialized commit on every `Context.to_dict()`/session save
|
|
203
|
+
is pure waste for a long-lived context — only new commits since the
|
|
204
|
+
last call actually get `Commit.to_dict()` called on them.
|
|
205
|
+
"""
|
|
206
|
+
result = []
|
|
207
|
+
for c in self._commits:
|
|
208
|
+
cached = self._dict_cache.get(c.id)
|
|
209
|
+
if cached is None:
|
|
210
|
+
cached = c.to_dict()
|
|
211
|
+
self._dict_cache[c.id] = cached
|
|
212
|
+
result.append(cached)
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def from_dict(
|
|
217
|
+
cls,
|
|
218
|
+
commits: list[dict[str, Any]],
|
|
219
|
+
*,
|
|
220
|
+
version: int | None,
|
|
221
|
+
head_id: str | None,
|
|
222
|
+
) -> CommitLog:
|
|
223
|
+
log = cls()
|
|
224
|
+
log._commits = [Commit.from_dict(cd) for cd in commits]
|
|
225
|
+
log._version = version if version is not None else len(log._commits)
|
|
226
|
+
log._head_id = (
|
|
227
|
+
head_id
|
|
228
|
+
if head_id is not None
|
|
229
|
+
else (log._commits[-1].id if log._commits else None)
|
|
230
|
+
)
|
|
231
|
+
log._rebuild_indices()
|
|
232
|
+
return log
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = ["CommitLog"]
|
reactifact/consume.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .artifacts import Artifact, ArtifactType
|
|
7
|
+
from .events import EventType
|
|
8
|
+
from .triggers import Trigger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Consume:
|
|
12
|
+
"""Describes the consumed artifact type, condition and triggering events.
|
|
13
|
+
|
|
14
|
+
All parameters can be set as class attributes (for inheritance)
|
|
15
|
+
or passed to the constructor.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
artifact_type: ArtifactType | None = None
|
|
19
|
+
condition: Callable[[Artifact[Any]], bool] | None = None
|
|
20
|
+
event_types: Sequence[EventType] = (
|
|
21
|
+
EventType.ARTIFACT_CREATED,
|
|
22
|
+
EventType.ARTIFACT_UPDATED,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
artifact_type: ArtifactType | None = None,
|
|
28
|
+
condition: Callable[[Artifact[Any]], bool] | None = None,
|
|
29
|
+
event_types: Sequence[EventType] | None = None,
|
|
30
|
+
):
|
|
31
|
+
self.artifact_type = artifact_type or self.__class__.artifact_type
|
|
32
|
+
if self.artifact_type is None:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"artifact_type must be provided either as class attribute or constructor argument"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.condition = (
|
|
38
|
+
condition if condition is not None else self.__class__.condition
|
|
39
|
+
)
|
|
40
|
+
self.event_types = list(
|
|
41
|
+
event_types if event_types is not None else self.__class__.event_types
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def to_triggers(self) -> list[Trigger]:
|
|
45
|
+
"""Converts into a list of triggers for automatic reaction."""
|
|
46
|
+
return [
|
|
47
|
+
Trigger(event_type, self.artifact_type, self.condition)
|
|
48
|
+
for event_type in self.event_types
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def by_status(
|
|
53
|
+
cls,
|
|
54
|
+
artifact_type: ArtifactType,
|
|
55
|
+
status: str,
|
|
56
|
+
event_types: Sequence[EventType] = (
|
|
57
|
+
EventType.ARTIFACT_CREATED,
|
|
58
|
+
EventType.ARTIFACT_UPDATED,
|
|
59
|
+
),
|
|
60
|
+
) -> Consume:
|
|
61
|
+
"""Creates a Consume with a condition on the equality of the status field."""
|
|
62
|
+
return cls(
|
|
63
|
+
artifact_type,
|
|
64
|
+
condition=lambda art: getattr(art.data, "status", None) == status,
|
|
65
|
+
event_types=event_types,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def by_field(
|
|
70
|
+
cls,
|
|
71
|
+
artifact_type: ArtifactType,
|
|
72
|
+
field: str,
|
|
73
|
+
value: Any,
|
|
74
|
+
event_types: Sequence[EventType] = (
|
|
75
|
+
EventType.ARTIFACT_CREATED,
|
|
76
|
+
EventType.ARTIFACT_UPDATED,
|
|
77
|
+
),
|
|
78
|
+
) -> Consume:
|
|
79
|
+
"""Creates a Consume with a condition on the equality of an arbitrary field."""
|
|
80
|
+
return cls(
|
|
81
|
+
artifact_type,
|
|
82
|
+
condition=lambda art: getattr(art.data, field, None) == value,
|
|
83
|
+
event_types=event_types,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def consume(
|
|
88
|
+
artifact_type: ArtifactType,
|
|
89
|
+
condition: Callable[[Artifact[Any]], bool] | None = None,
|
|
90
|
+
event_types: Sequence[EventType] = (
|
|
91
|
+
EventType.ARTIFACT_CREATED,
|
|
92
|
+
EventType.ARTIFACT_UPDATED,
|
|
93
|
+
),
|
|
94
|
+
) -> Consume:
|
|
95
|
+
"""Factory for quickly creating a Consume."""
|
|
96
|
+
return Consume(artifact_type, condition, event_types)
|