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
reactifact/__init__.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""reactifact's core public API.
|
|
2
|
+
|
|
3
|
+
Deliberately small: the primitives from the README's "Core primitives"
|
|
4
|
+
section, plus the everyday building blocks (tool calling, sessions, the LLM
|
|
5
|
+
provider protocol) most agents need regardless of what else they use.
|
|
6
|
+
|
|
7
|
+
Everything else — eval, tracing, checkpoint/branch backends beyond the
|
|
8
|
+
in-memory default, the chat/web layer, the adaptive scheduler, replay,
|
|
9
|
+
structured-LLM helpers, viz, prompts — is one level down, in its own
|
|
10
|
+
submodule (`reactifact.eval`, `reactifact.tracing`, `reactifact.chat`, ...). Import it
|
|
11
|
+
from there:
|
|
12
|
+
|
|
13
|
+
from reactifact.structured import structured_llm
|
|
14
|
+
from reactifact.tracing import TraceStore
|
|
15
|
+
from reactifact.chat import ChatAssistant
|
|
16
|
+
|
|
17
|
+
This keeps `dir(reactifact)` / editor autocomplete to what you need to build a
|
|
18
|
+
first agent, and keeps optional-dependency features (Postgres, FastAPI) out
|
|
19
|
+
of the names you see by default even though they were always cheap to import
|
|
20
|
+
(the driver itself is still lazily imported inside the class, see
|
|
21
|
+
`reactifact._extras`).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .agents import Agent, create_agent
|
|
25
|
+
from .artifacts import Artifact
|
|
26
|
+
from .branching import MergeConflict
|
|
27
|
+
from .budget import Budget, RunOutcome, RunStats
|
|
28
|
+
from .consume import Consume, consume
|
|
29
|
+
from .context import Context, View
|
|
30
|
+
from .effects import Effects, Handle
|
|
31
|
+
from .events import Event, EventType
|
|
32
|
+
from .interrupt import PendingQuestion
|
|
33
|
+
from .patches import Create, Delete, Link, Patch, Relation, Unlink, Update
|
|
34
|
+
from .produce import Produce, produce
|
|
35
|
+
from .providers import (
|
|
36
|
+
EmbeddingProvider,
|
|
37
|
+
FakeEmbedder,
|
|
38
|
+
FakeLLM,
|
|
39
|
+
LLMProvider,
|
|
40
|
+
LLMRequest,
|
|
41
|
+
LLMResponse,
|
|
42
|
+
LLMResponseChunk,
|
|
43
|
+
Message,
|
|
44
|
+
)
|
|
45
|
+
from .resources import RuntimeResources
|
|
46
|
+
from .runtime import Runtime
|
|
47
|
+
from .session import Session, SessionStore
|
|
48
|
+
from .tools import FunctionTool, Tool, ToolOutput, tool
|
|
49
|
+
from .triggers import Trigger
|
|
50
|
+
|
|
51
|
+
__version__ = "0.6.0"
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"Agent",
|
|
55
|
+
"Artifact",
|
|
56
|
+
"Budget",
|
|
57
|
+
"Consume",
|
|
58
|
+
"Context",
|
|
59
|
+
"Create",
|
|
60
|
+
"Delete",
|
|
61
|
+
"EmbeddingProvider",
|
|
62
|
+
"Effects",
|
|
63
|
+
"Event",
|
|
64
|
+
"EventType",
|
|
65
|
+
"FakeEmbedder",
|
|
66
|
+
"FakeLLM",
|
|
67
|
+
"FunctionTool",
|
|
68
|
+
"Handle",
|
|
69
|
+
"Link",
|
|
70
|
+
"Message",
|
|
71
|
+
"MergeConflict",
|
|
72
|
+
"Patch",
|
|
73
|
+
"PendingQuestion",
|
|
74
|
+
"Produce",
|
|
75
|
+
"Relation",
|
|
76
|
+
"RunOutcome",
|
|
77
|
+
"RunStats",
|
|
78
|
+
"Runtime",
|
|
79
|
+
"RuntimeResources",
|
|
80
|
+
"LLMProvider",
|
|
81
|
+
"LLMRequest",
|
|
82
|
+
"LLMResponse",
|
|
83
|
+
"LLMResponseChunk",
|
|
84
|
+
"Session",
|
|
85
|
+
"SessionStore",
|
|
86
|
+
"Tool",
|
|
87
|
+
"ToolOutput",
|
|
88
|
+
"Trigger",
|
|
89
|
+
"Unlink",
|
|
90
|
+
"Update",
|
|
91
|
+
"View",
|
|
92
|
+
"consume",
|
|
93
|
+
"create_agent",
|
|
94
|
+
"produce",
|
|
95
|
+
"tool",
|
|
96
|
+
]
|
reactifact/__main__.py
ADDED
reactifact/_extras.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""reactifact._extras — readable errors for optional dependencies.
|
|
2
|
+
|
|
3
|
+
Core must stay dependency-free; extra-gated features lazily import their
|
|
4
|
+
driver. Instead of a bare `ModuleNotFoundError`, `require_extra` explains what
|
|
5
|
+
it needs and how to install it:
|
|
6
|
+
|
|
7
|
+
from reactifact._extras import require_extra
|
|
8
|
+
psycopg = require_extra("PostgreSQLKVBackend", "psycopg", "pg")
|
|
9
|
+
|
|
10
|
+
# -> ImportError:
|
|
11
|
+
# reactifact.PostgreSQLKVBackend requires the 'pg' extra (psycopg).
|
|
12
|
+
# Install it with `pip install "reactifact[pg]"` or `uv sync --extra pg`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require_extra(feature: str, module: str, extra: str) -> Any:
|
|
22
|
+
"""Imports `module`, or raises a readable error with the install hint."""
|
|
23
|
+
try:
|
|
24
|
+
return importlib.import_module(module)
|
|
25
|
+
except ModuleNotFoundError as exc:
|
|
26
|
+
missing = exc.name or module
|
|
27
|
+
if not (missing == module or missing.startswith(module + ".")):
|
|
28
|
+
raise # not our dependency — re-raise the original error
|
|
29
|
+
raise ImportError(
|
|
30
|
+
f"reactifact.{feature} requires the {extra!r} extra ({module}). "
|
|
31
|
+
f'Install it with `pip install "reactifact[{extra}]"` '
|
|
32
|
+
f"or `uv sync --extra {extra}`."
|
|
33
|
+
) from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["require_extra"]
|
reactifact/agents.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .artifacts import Artifact
|
|
8
|
+
from .consume import Consume
|
|
9
|
+
from .context import Context
|
|
10
|
+
from .events import Event
|
|
11
|
+
from .patches import Patch
|
|
12
|
+
from .produce import Produce
|
|
13
|
+
from .triggers import Trigger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Agent(ABC): # noqa: B024 — interface without abstract methods, run() has a default
|
|
17
|
+
"""Base container: consumes some artifacts and produces others.
|
|
18
|
+
|
|
19
|
+
If `run` is not overridden, automatically collects inputs according to
|
|
20
|
+
consumes and calls produce on every produce, merging the patches.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str = ""
|
|
24
|
+
consumes: Sequence[Consume] | None = None
|
|
25
|
+
produces: Sequence[Produce[Any]] | None = None
|
|
26
|
+
#: Declarative capability labels (§25), consumed by the adaptive policy.
|
|
27
|
+
capabilities: tuple[str, ...] = ()
|
|
28
|
+
triggers: list[Trigger] = []
|
|
29
|
+
# Run priority within a single generation: lower value runs earlier.
|
|
30
|
+
# Useful for "finishers"/evaluators that logically run last (§24).
|
|
31
|
+
priority: int = 0
|
|
32
|
+
# Max parallel executions of this agent within a generation. Leave None for
|
|
33
|
+
# the runtime default (max_concurrency). Use it to throttle LLM-bound
|
|
34
|
+
# producers (rate limits) independently of cheap I/O (file reads).
|
|
35
|
+
concurrency_limit: int | None = None
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
name: str | None = None,
|
|
40
|
+
triggers: list[Trigger] | None = None,
|
|
41
|
+
priority: int | None = None,
|
|
42
|
+
):
|
|
43
|
+
self.name = name or self.name or self.__class__.__name__
|
|
44
|
+
|
|
45
|
+
self.priority = (
|
|
46
|
+
priority if priority is not None else getattr(self.__class__, "priority", 0)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if triggers is not None:
|
|
50
|
+
self.triggers = list(triggers)
|
|
51
|
+
elif self.triggers:
|
|
52
|
+
self.triggers = list(self.triggers)
|
|
53
|
+
elif self.consumes is not None:
|
|
54
|
+
self.triggers = self._generate_triggers_from_consumes()
|
|
55
|
+
else:
|
|
56
|
+
self.triggers = []
|
|
57
|
+
|
|
58
|
+
self._validate_contracts()
|
|
59
|
+
|
|
60
|
+
def _generate_triggers_from_consumes(self) -> list[Trigger]:
|
|
61
|
+
result = []
|
|
62
|
+
for c in self.consumes or []:
|
|
63
|
+
result.extend(c.to_triggers())
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
def _validate_contracts(self) -> None:
|
|
67
|
+
if self.consumes is not None:
|
|
68
|
+
for c in self.consumes:
|
|
69
|
+
if not isinstance(c, Consume):
|
|
70
|
+
raise TypeError(
|
|
71
|
+
f"Agent {self.name!r}: consumes must contain Consume "
|
|
72
|
+
f"instances, got {type(c)}"
|
|
73
|
+
)
|
|
74
|
+
if self.produces is not None:
|
|
75
|
+
for p in self.produces:
|
|
76
|
+
if not isinstance(p, Produce):
|
|
77
|
+
raise TypeError(
|
|
78
|
+
f"Agent {self.name!r}: produces must contain Produce "
|
|
79
|
+
f"instances, got {type(p)}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def matches(self, event: Event, context: Context | None = None) -> bool:
|
|
83
|
+
return any(trigger.matches(event, context) for trigger in self.triggers)
|
|
84
|
+
|
|
85
|
+
def collect_inputs(self, context: Context) -> list[Artifact[Any]]:
|
|
86
|
+
"""Public access to the consumed artifacts.
|
|
87
|
+
|
|
88
|
+
Used by the runtime to record the reads linkage (provenance) on run.
|
|
89
|
+
"""
|
|
90
|
+
return self._collect_inputs(context)
|
|
91
|
+
|
|
92
|
+
def _collect_inputs(self, context: Context) -> list[Artifact[Any]]:
|
|
93
|
+
"""Collects all artifacts matching consumes and conditions."""
|
|
94
|
+
if not self.consumes:
|
|
95
|
+
return []
|
|
96
|
+
inputs: list[Artifact[Any]] = []
|
|
97
|
+
for c in self.consumes:
|
|
98
|
+
artifacts = context.list_artifacts(c.artifact_type)
|
|
99
|
+
if c.condition:
|
|
100
|
+
artifacts = [a for a in artifacts if c.condition(a)]
|
|
101
|
+
inputs.extend(artifacts)
|
|
102
|
+
return inputs
|
|
103
|
+
|
|
104
|
+
async def run(self, event: Event, context: Context) -> Patch | None:
|
|
105
|
+
"""Default: runs `self.produces` via `execute()` (the effects-first
|
|
106
|
+
path — write a `Produce` subclass or `@produce` function instead of
|
|
107
|
+
overriding this).
|
|
108
|
+
|
|
109
|
+
Overriding `run()` to return a `Patch` by hand is a low-level,
|
|
110
|
+
internal escape hatch for cases `effects` genuinely can't express —
|
|
111
|
+
not a third everyday produce style (see `reactifact.produce`'s module
|
|
112
|
+
docstring for the two you should reach for first). No example in
|
|
113
|
+
this repo overrides it; only this repo's own tests do.
|
|
114
|
+
"""
|
|
115
|
+
await self.execute(context, event)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
async def execute(self, context: Context, event: Event | None = None) -> None:
|
|
119
|
+
"""Runs the agent's produces (usually on an event).
|
|
120
|
+
|
|
121
|
+
Effects-first (§24): produces write `self.effects.*` and return None;
|
|
122
|
+
the *runtime* compiles the effect slot into one atomic patch. This method
|
|
123
|
+
only *runs* the produces — it does not build a patch. (`run` remains the
|
|
124
|
+
agent-level escape hatch for custom Agent subclasses that assemble a
|
|
125
|
+
change-set by hand; the runtime merges its result after the effects.)
|
|
126
|
+
"""
|
|
127
|
+
if not self.produces:
|
|
128
|
+
return None
|
|
129
|
+
inputs = self._collect_inputs(context)
|
|
130
|
+
for p in self.produces:
|
|
131
|
+
await p.produce(context, inputs, event)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def create_agent(
|
|
136
|
+
name: str,
|
|
137
|
+
*,
|
|
138
|
+
consumes: Sequence[Consume] | None = None,
|
|
139
|
+
produces: Sequence[Produce[Any]] | None = None,
|
|
140
|
+
capabilities: tuple[str, ...] = (),
|
|
141
|
+
priority: int = 0,
|
|
142
|
+
concurrency_limit: int | None = None,
|
|
143
|
+
triggers: list[Trigger] | None = None,
|
|
144
|
+
) -> Agent:
|
|
145
|
+
"""Builds an Agent instance without subclassing.
|
|
146
|
+
|
|
147
|
+
`Agent` is a container — nothing needs overriding in the common case — so a
|
|
148
|
+
subclass is only ceremony. This constructor-style builder covers all of the
|
|
149
|
+
declarative knobs:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
echo = create_agent(
|
|
153
|
+
name="echo",
|
|
154
|
+
consumes=[Consume(Question)],
|
|
155
|
+
produces=[echo_produce], # a Produce or @produce(...) function
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Falls back to `name` defaults the same way as `Agent.__init__`.
|
|
160
|
+
"""
|
|
161
|
+
agent = Agent(
|
|
162
|
+
name=name, triggers=triggers if triggers is not None else [], priority=priority
|
|
163
|
+
)
|
|
164
|
+
if consumes is not None:
|
|
165
|
+
agent.consumes = consumes
|
|
166
|
+
if triggers is None:
|
|
167
|
+
agent.triggers = agent._generate_triggers_from_consumes()
|
|
168
|
+
if produces is not None:
|
|
169
|
+
agent.produces = produces
|
|
170
|
+
agent.capabilities = capabilities
|
|
171
|
+
agent.concurrency_limit = concurrency_limit
|
|
172
|
+
agent._validate_contracts()
|
|
173
|
+
return agent
|
reactifact/artifacts.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
TData = TypeVar("TData", bound=BaseModel)
|
|
11
|
+
ArtifactType = type[BaseModel]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compute_dict_diff(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]:
|
|
15
|
+
"""Simple dictionary comparison: returns changed, added and removed keys."""
|
|
16
|
+
diff: dict[str, Any] = {"changed": {}, "added": {}, "removed": {}}
|
|
17
|
+
all_keys = set(old.keys()) | set(new.keys())
|
|
18
|
+
for key in all_keys:
|
|
19
|
+
if key not in old:
|
|
20
|
+
diff["added"][key] = new[key]
|
|
21
|
+
elif key not in new:
|
|
22
|
+
diff["removed"][key] = old[key]
|
|
23
|
+
elif old[key] != new[key]:
|
|
24
|
+
# If both values are dictionaries, compare them recursively
|
|
25
|
+
if isinstance(old[key], dict) and isinstance(new[key], dict):
|
|
26
|
+
nested = compute_dict_diff(old[key], new[key])
|
|
27
|
+
if any(nested.values()):
|
|
28
|
+
diff["changed"][key] = nested
|
|
29
|
+
else:
|
|
30
|
+
diff["changed"][key] = {"old": old[key], "new": new[key]}
|
|
31
|
+
return diff
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _import_class(full_name: str) -> Any:
|
|
35
|
+
module_name, class_name = full_name.rsplit(".", 1)
|
|
36
|
+
module = importlib.import_module(module_name)
|
|
37
|
+
return getattr(module, class_name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Artifact(Generic[TData]):
|
|
41
|
+
"""Wrapper around a Pydantic model with versioning."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
data: TData,
|
|
46
|
+
id: str | None = None,
|
|
47
|
+
created_by_commit: str | None = None,
|
|
48
|
+
):
|
|
49
|
+
self.id = id or str(uuid.uuid4())
|
|
50
|
+
self.data = data
|
|
51
|
+
self.data_type = f"{type(data).__module__}.{type(data).__qualname__}"
|
|
52
|
+
self.created_at = datetime.now(UTC)
|
|
53
|
+
self.updated_at = self.created_at
|
|
54
|
+
self.created_by_commit = created_by_commit
|
|
55
|
+
self._history: list[
|
|
56
|
+
TData
|
|
57
|
+
] = [] # previous data versions (excluding the current one)
|
|
58
|
+
# Memoized to_dict(), keyed by version: re-derived on every save()
|
|
59
|
+
# otherwise (session persistence saves after every commit, §reactifact.session)
|
|
60
|
+
# even though most artifacts in a large context are unchanged since the
|
|
61
|
+
# last save — see reactifact/session.py for the calling context.
|
|
62
|
+
self._dict_cache: tuple[int, dict[str, Any]] | None = None
|
|
63
|
+
|
|
64
|
+
def update(self, new_data: TData) -> None:
|
|
65
|
+
"""Saves the current version to history and replaces the data."""
|
|
66
|
+
self._history.append(self.data)
|
|
67
|
+
self.data = new_data
|
|
68
|
+
self.updated_at = datetime.now(UTC)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def history(self) -> list[TData]:
|
|
72
|
+
"""Returns a copy of the list of previous versions (excluding the current one)."""
|
|
73
|
+
return list(self._history)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def version(self) -> int:
|
|
77
|
+
"""Current version (0 – original, 1 – after the first update, etc.)"""
|
|
78
|
+
return len(self._history)
|
|
79
|
+
|
|
80
|
+
def get_all_versions(self) -> list[TData]:
|
|
81
|
+
"""Returns all data versions, including the current one, from oldest to newest."""
|
|
82
|
+
return self._history + [self.data]
|
|
83
|
+
|
|
84
|
+
def diff(self, old_version: int, new_version: int) -> dict[str, Any]:
|
|
85
|
+
"""Returns a diff between two versions by their numbers (0 – the oldest)."""
|
|
86
|
+
versions = self.get_all_versions()
|
|
87
|
+
if old_version < 0 or new_version >= len(versions) or old_version > new_version:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"Invalid version indices: old={old_version}, new={new_version}"
|
|
90
|
+
)
|
|
91
|
+
old_data = versions[old_version].model_dump()
|
|
92
|
+
new_data = versions[new_version].model_dump()
|
|
93
|
+
return compute_dict_diff(old_data, new_data)
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict[str, Any]:
|
|
96
|
+
"""Serializes the artifact, including its full version history.
|
|
97
|
+
|
|
98
|
+
Memoized per `version` (bumped by `update()`): an unchanged artifact
|
|
99
|
+
returns the same dict instance on a later call instead of re-walking
|
|
100
|
+
`model_dump()` over its entire history again — the history only grows
|
|
101
|
+
monotonically, so a cache keyed by version can never go stale.
|
|
102
|
+
"""
|
|
103
|
+
if self._dict_cache is not None and self._dict_cache[0] == self.version:
|
|
104
|
+
return self._dict_cache[1]
|
|
105
|
+
d = {
|
|
106
|
+
"id": self.id,
|
|
107
|
+
"data_type": self.data_type,
|
|
108
|
+
"data": self.data.model_dump(mode="json"),
|
|
109
|
+
"created_at": self.created_at.isoformat(),
|
|
110
|
+
"updated_at": self.updated_at.isoformat(),
|
|
111
|
+
"history": [v.model_dump(mode="json") for v in self._history],
|
|
112
|
+
"created_by_commit": self.created_by_commit,
|
|
113
|
+
}
|
|
114
|
+
self._dict_cache = (self.version, d)
|
|
115
|
+
return d
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_dict(cls, d: dict[str, Any]) -> Artifact[Any]:
|
|
119
|
+
model_class = _import_class(d["data_type"])
|
|
120
|
+
data = model_class.model_validate(d["data"])
|
|
121
|
+
artifact = cls(
|
|
122
|
+
data=data, id=d["id"], created_by_commit=d.get("created_by_commit")
|
|
123
|
+
)
|
|
124
|
+
artifact.created_at = datetime.fromisoformat(d["created_at"])
|
|
125
|
+
artifact.updated_at = datetime.fromisoformat(d["updated_at"])
|
|
126
|
+
artifact._history = [model_class.model_validate(h) for h in d["history"]]
|
|
127
|
+
return artifact
|
|
128
|
+
|
|
129
|
+
def __repr__(self) -> str:
|
|
130
|
+
return f"<Artifact id={self.id!r} type={self.data.__class__.__name__} v{self.version}>"
|