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.
Files changed (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. 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)
@@ -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
+ )
@@ -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)