activegraph 1.0.0rc2__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.
- activegraph/__init__.py +222 -0
- activegraph/__main__.py +5 -0
- activegraph/behaviors/__init__.py +1 -0
- activegraph/behaviors/base.py +153 -0
- activegraph/behaviors/decorators.py +218 -0
- activegraph/cli/__init__.py +17 -0
- activegraph/cli/main.py +793 -0
- activegraph/cli/quickstart.py +556 -0
- activegraph/core/__init__.py +1 -0
- activegraph/core/clock.py +46 -0
- activegraph/core/event.py +33 -0
- activegraph/core/graph.py +846 -0
- activegraph/core/ids.py +135 -0
- activegraph/core/patch.py +47 -0
- activegraph/core/view.py +59 -0
- activegraph/errors.py +342 -0
- activegraph/frame.py +15 -0
- activegraph/llm/__init__.py +51 -0
- activegraph/llm/anthropic.py +339 -0
- activegraph/llm/cache.py +180 -0
- activegraph/llm/errors.py +257 -0
- activegraph/llm/prompt.py +420 -0
- activegraph/llm/provider.py +66 -0
- activegraph/llm/recorded.py +270 -0
- activegraph/llm/types.py +130 -0
- activegraph/observability/__init__.py +61 -0
- activegraph/observability/logging.py +205 -0
- activegraph/observability/metrics.py +219 -0
- activegraph/observability/migration.py +404 -0
- activegraph/observability/prometheus.py +101 -0
- activegraph/observability/status.py +83 -0
- activegraph/packs/__init__.py +999 -0
- activegraph/packs/diligence/__init__.py +86 -0
- activegraph/packs/diligence/behaviors.py +447 -0
- activegraph/packs/diligence/fixtures/__init__.py +364 -0
- activegraph/packs/diligence/fixtures/companies.py +511 -0
- activegraph/packs/diligence/object_types.py +163 -0
- activegraph/packs/diligence/settings.py +60 -0
- activegraph/packs/diligence/tools.py +124 -0
- activegraph/packs/loader.py +709 -0
- activegraph/packs/scaffold.py +317 -0
- activegraph/policy.py +20 -0
- activegraph/runtime/__init__.py +1 -0
- activegraph/runtime/behavior_graph.py +151 -0
- activegraph/runtime/budget.py +120 -0
- activegraph/runtime/config_errors.py +124 -0
- activegraph/runtime/diff.py +145 -0
- activegraph/runtime/errors.py +216 -0
- activegraph/runtime/exec_errors.py +232 -0
- activegraph/runtime/patterns.py +946 -0
- activegraph/runtime/queue.py +27 -0
- activegraph/runtime/registration_errors.py +291 -0
- activegraph/runtime/registry.py +111 -0
- activegraph/runtime/runtime.py +2441 -0
- activegraph/runtime/scheduler.py +206 -0
- activegraph/runtime/view_builder.py +65 -0
- activegraph/store/__init__.py +41 -0
- activegraph/store/base.py +66 -0
- activegraph/store/conformance.py +161 -0
- activegraph/store/errors.py +84 -0
- activegraph/store/memory.py +118 -0
- activegraph/store/postgres.py +609 -0
- activegraph/store/serde.py +202 -0
- activegraph/store/sqlite.py +446 -0
- activegraph/store/url.py +230 -0
- activegraph/tools/__init__.py +64 -0
- activegraph/tools/base.py +57 -0
- activegraph/tools/cache.py +157 -0
- activegraph/tools/context.py +48 -0
- activegraph/tools/decorators.py +70 -0
- activegraph/tools/errors.py +320 -0
- activegraph/tools/graph_query.py +94 -0
- activegraph/tools/recorded.py +205 -0
- activegraph/tools/web_fetch.py +80 -0
- activegraph/trace/__init__.py +1 -0
- activegraph/trace/causal.py +123 -0
- activegraph/trace/printer.py +495 -0
- activegraph-1.0.0rc2.dist-info/METADATA +228 -0
- activegraph-1.0.0rc2.dist-info/RECORD +82 -0
- activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
- activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
- activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Event-count scheduler for `activate_after`. CONTRACT v0.7 #13.
|
|
2
|
+
|
|
3
|
+
The decision: v0.7's `activate_after` is event-count only, never
|
|
4
|
+
wall-clock. Wall-clock would require a clock-source abstraction and
|
|
5
|
+
break determinism under replay. Event-count is deterministic across
|
|
6
|
+
replay, composes with the tick model, and the escape hatch ("user
|
|
7
|
+
drives `runtime.tick()` and injects timer.fired events") is the v1+
|
|
8
|
+
extension point if anyone needs wall-clock.
|
|
9
|
+
|
|
10
|
+
How it works:
|
|
11
|
+
|
|
12
|
+
* When a triggering event matches a behavior with `activate_after=N`,
|
|
13
|
+
the runtime calls `scheduler.schedule(...)` instead of invoking
|
|
14
|
+
the behavior directly. A `behavior.scheduled` event is emitted.
|
|
15
|
+
|
|
16
|
+
* The scheduler tracks each pending invocation with a fire-at-event
|
|
17
|
+
counter (= current_event_count + N).
|
|
18
|
+
|
|
19
|
+
* After every non-lifecycle event the runtime processes, it calls
|
|
20
|
+
`scheduler.due(graph)` which returns the pending entries whose
|
|
21
|
+
fire counters have been reached.
|
|
22
|
+
|
|
23
|
+
* For each due entry, the runtime RE-CHECKS the `where=` clause
|
|
24
|
+
against the latest graph state before invoking. If it no longer
|
|
25
|
+
holds, the invocation is silently skipped (no extra event). If it
|
|
26
|
+
holds, the behavior fires normally.
|
|
27
|
+
|
|
28
|
+
Parsing `activate_after`:
|
|
29
|
+
* int → number of events
|
|
30
|
+
* str "N" or "N events" / "N event" → N events
|
|
31
|
+
* anything else → ValueError at registration time
|
|
32
|
+
|
|
33
|
+
Anything wall-clock (e.g. "5 minutes") is intentionally rejected with
|
|
34
|
+
a clear message pointing at CONTRACT v0.7 #13.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import re
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from typing import Any, Optional
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ScheduledEntry:
|
|
46
|
+
behavior_name: str
|
|
47
|
+
behavior_index: int # registry index — preserves CONTRACT #10 order
|
|
48
|
+
triggering_event_id: str
|
|
49
|
+
fire_at_event_count: int
|
|
50
|
+
where_recheck_path: Optional[str] # behavior's `where=` payload path is kept
|
|
51
|
+
scheduled_event_id: str # the behavior.scheduled event id
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DelayedQueue:
|
|
56
|
+
"""Pending `activate_after` invocations. FIFO within the same fire tick."""
|
|
57
|
+
|
|
58
|
+
entries: list[ScheduledEntry] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
def push(self, entry: ScheduledEntry) -> None:
|
|
61
|
+
self.entries.append(entry)
|
|
62
|
+
|
|
63
|
+
def pop_due(self, current_event_count: int) -> list[ScheduledEntry]:
|
|
64
|
+
due: list[ScheduledEntry] = []
|
|
65
|
+
kept: list[ScheduledEntry] = []
|
|
66
|
+
for e in self.entries:
|
|
67
|
+
if e.fire_at_event_count <= current_event_count:
|
|
68
|
+
due.append(e)
|
|
69
|
+
else:
|
|
70
|
+
kept.append(e)
|
|
71
|
+
self.entries = kept
|
|
72
|
+
return due
|
|
73
|
+
|
|
74
|
+
def __len__(self) -> int:
|
|
75
|
+
return len(self.entries)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_DURATION_RE = re.compile(
|
|
79
|
+
r"^\s*(\d+)\s*(events?)?\s*$", re.IGNORECASE
|
|
80
|
+
)
|
|
81
|
+
_WALL_CLOCK_WORDS = {
|
|
82
|
+
"second", "seconds", "ms", "millisecond", "milliseconds",
|
|
83
|
+
"minute", "minutes", "min", "mins",
|
|
84
|
+
"hour", "hours", "day", "days", "week", "weeks",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
from activegraph.errors import RegistrationError as _RegistrationError
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class InvalidActivateAfter(_RegistrationError, ValueError):
|
|
92
|
+
"""``activate_after=`` on a @behavior / @llm_behavior decorator was
|
|
93
|
+
passed an unparseable or out-of-range value.
|
|
94
|
+
|
|
95
|
+
Multi-inherits :class:`ValueError` for back-compat with user code
|
|
96
|
+
catching the builtin around behavior registration.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
_doc_slug = "invalid-activate-after"
|
|
100
|
+
|
|
101
|
+
def __init__(self, *, spec: Any, kind: str, hint: str) -> None:
|
|
102
|
+
self.spec = spec
|
|
103
|
+
self.kind = kind
|
|
104
|
+
self.hint = hint
|
|
105
|
+
from activegraph.errors import RegistrationError as _R
|
|
106
|
+
_R.__init__(
|
|
107
|
+
self,
|
|
108
|
+
f"activate_after={spec!r} is invalid ({kind})",
|
|
109
|
+
what_failed=(
|
|
110
|
+
f"A behavior decorator was given `activate_after={spec!r}`, "
|
|
111
|
+
f"which the scheduler refused as {kind}."
|
|
112
|
+
),
|
|
113
|
+
why=(
|
|
114
|
+
"`activate_after` schedules a behavior to fire N events after "
|
|
115
|
+
"its triggering event. The runtime evaluates the schedule "
|
|
116
|
+
"against the event log (not wall-clock) so replay produces "
|
|
117
|
+
"identical timing — wall-clock units are intentionally out "
|
|
118
|
+
"of scope (CONTRACT v0.7 #13). An unparseable value would "
|
|
119
|
+
"make scheduling silently wrong, which would corrupt the "
|
|
120
|
+
"audit trail at every replay."
|
|
121
|
+
),
|
|
122
|
+
how_to_fix=hint,
|
|
123
|
+
context={"spec": repr(spec), "kind": kind},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_activate_after(spec: Any) -> int:
|
|
128
|
+
"""Parse `activate_after=` into an integer event count.
|
|
129
|
+
|
|
130
|
+
Accepts:
|
|
131
|
+
- int N (N >= 1)
|
|
132
|
+
- str "N", "N event", "N events"
|
|
133
|
+
|
|
134
|
+
Rejects:
|
|
135
|
+
- 0 or negative
|
|
136
|
+
- any wall-clock unit (seconds, minutes, ...): wall-clock is
|
|
137
|
+
intentionally out of scope for v0.7 (see CONTRACT v0.7 #13).
|
|
138
|
+
"""
|
|
139
|
+
if isinstance(spec, bool):
|
|
140
|
+
# bool is an int in Python — guard against accidental True/False.
|
|
141
|
+
raise InvalidActivateAfter(
|
|
142
|
+
spec=spec,
|
|
143
|
+
kind="bool not int",
|
|
144
|
+
hint=(
|
|
145
|
+
"Pass an integer event count instead:\n"
|
|
146
|
+
" activate_after=5\n"
|
|
147
|
+
"or a string with the 'events' unit:\n"
|
|
148
|
+
" activate_after='5 events'"
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
if isinstance(spec, int):
|
|
152
|
+
n = spec
|
|
153
|
+
elif isinstance(spec, str):
|
|
154
|
+
s = spec.strip().lower()
|
|
155
|
+
# Detect wall-clock units and reject with a CONTRACT-pointing message.
|
|
156
|
+
for word in _WALL_CLOCK_WORDS:
|
|
157
|
+
if word in s.split():
|
|
158
|
+
raise InvalidActivateAfter(
|
|
159
|
+
spec=spec,
|
|
160
|
+
kind="wall-clock unit",
|
|
161
|
+
hint=(
|
|
162
|
+
f"Wall-clock units (seconds/minutes/hours) are not "
|
|
163
|
+
f"supported. Express the delay as an event count:\n"
|
|
164
|
+
f" activate_after=5\n"
|
|
165
|
+
f" activate_after='5 events'\n"
|
|
166
|
+
f"\n"
|
|
167
|
+
f"If you genuinely need wall-clock scheduling, file "
|
|
168
|
+
f"an issue — the v1+ contract leaves room for it "
|
|
169
|
+
f"behind a separate primitive (see CONTRACT v0.7 #23)."
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
m = _DURATION_RE.match(s)
|
|
173
|
+
if not m:
|
|
174
|
+
raise InvalidActivateAfter(
|
|
175
|
+
spec=spec,
|
|
176
|
+
kind="unparseable string",
|
|
177
|
+
hint=(
|
|
178
|
+
"Use one of:\n"
|
|
179
|
+
" activate_after=5\n"
|
|
180
|
+
" activate_after='5'\n"
|
|
181
|
+
" activate_after='5 events'\n"
|
|
182
|
+
" activate_after='5 event'"
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
n = int(m.group(1))
|
|
186
|
+
else:
|
|
187
|
+
raise InvalidActivateAfter(
|
|
188
|
+
spec=spec,
|
|
189
|
+
kind=f"type {type(spec).__name__}",
|
|
190
|
+
hint=(
|
|
191
|
+
"Pass an int or a string. Example:\n"
|
|
192
|
+
" activate_after=5\n"
|
|
193
|
+
" activate_after='5 events'"
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
if n < 1:
|
|
197
|
+
raise InvalidActivateAfter(
|
|
198
|
+
spec=spec,
|
|
199
|
+
kind="must be >= 1",
|
|
200
|
+
hint=(
|
|
201
|
+
"`activate_after` schedules N events after the trigger. "
|
|
202
|
+
"Zero or negative N has no defined meaning. The minimum "
|
|
203
|
+
"is 1 (fire on the very next event after the trigger)."
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
return n
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Construct a View for a behavior invocation. CONTRACT #11."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from activegraph.behaviors.base import Behavior, RelationBehavior
|
|
8
|
+
from activegraph.core.event import Event
|
|
9
|
+
from activegraph.core.graph import Graph
|
|
10
|
+
from activegraph.core.view import View
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_RECENT_EVENTS = 50
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_view(
|
|
17
|
+
behavior: Behavior | RelationBehavior, event: Event, graph: Graph
|
|
18
|
+
) -> View:
|
|
19
|
+
spec = behavior.view_spec
|
|
20
|
+
if not spec:
|
|
21
|
+
return View(
|
|
22
|
+
objects=graph.all_objects(),
|
|
23
|
+
relations=graph.all_relations(),
|
|
24
|
+
events=graph.events[-DEFAULT_RECENT_EVENTS:],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
around_path = spec.get("around")
|
|
28
|
+
depth = spec.get("depth", 1)
|
|
29
|
+
include_types: Optional[list[str]] = spec.get("include_types")
|
|
30
|
+
recent_events: int = spec.get("recent_events", DEFAULT_RECENT_EVENTS)
|
|
31
|
+
|
|
32
|
+
if around_path:
|
|
33
|
+
center_id = _resolve_event_path(around_path, event)
|
|
34
|
+
objs, rels = graph.neighborhood(center_id, depth=depth) if center_id else ([], [])
|
|
35
|
+
else:
|
|
36
|
+
objs = graph.all_objects()
|
|
37
|
+
rels = graph.all_relations()
|
|
38
|
+
|
|
39
|
+
if include_types:
|
|
40
|
+
type_set = set(include_types)
|
|
41
|
+
objs = [o for o in objs if o.type in type_set]
|
|
42
|
+
|
|
43
|
+
return View(
|
|
44
|
+
objects=objs,
|
|
45
|
+
relations=rels,
|
|
46
|
+
events=graph.events[-recent_events:] if recent_events > 0 else [],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_event_path(expr: str, event: Event) -> Any:
|
|
51
|
+
"""Resolve `event.payload.object.id` against an Event."""
|
|
52
|
+
parts = expr.split(".")
|
|
53
|
+
if not parts or parts[0] != "event":
|
|
54
|
+
return None
|
|
55
|
+
cur: Any = event
|
|
56
|
+
for p in parts[1:]:
|
|
57
|
+
if isinstance(cur, dict):
|
|
58
|
+
cur = cur.get(p)
|
|
59
|
+
elif hasattr(cur, p):
|
|
60
|
+
cur = getattr(cur, p)
|
|
61
|
+
else:
|
|
62
|
+
return None
|
|
63
|
+
if cur is None:
|
|
64
|
+
return None
|
|
65
|
+
return cur
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Pluggable event-log stores. CONTRACT v0.5 #2, v0.8 #1–#2.
|
|
2
|
+
|
|
3
|
+
Implementations:
|
|
4
|
+
- InMemoryEventStore (v0.5): volatile, used by tests and ephemeral runs.
|
|
5
|
+
- SQLiteEventStore (v0.5): durable single-file. Default for solo work.
|
|
6
|
+
- PostgresEventStore (v0.8): shared-state, multi-process. Opt-in dep.
|
|
7
|
+
|
|
8
|
+
The EventStore protocol lives in ``store.base``. Custom backends conform
|
|
9
|
+
to that protocol; nothing in the runtime imports concrete stores directly.
|
|
10
|
+
The ``open_store(url, run_id)`` entry point picks the right driver from a
|
|
11
|
+
connection URL (sqlite:///... or postgres://...).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from activegraph.store.base import EventStore, RunRecord, replay_into
|
|
15
|
+
from activegraph.store.errors import (
|
|
16
|
+
CorruptedEventPayloadError,
|
|
17
|
+
DuplicateEventError,
|
|
18
|
+
EventNotFoundError,
|
|
19
|
+
SchemaVersionMismatch,
|
|
20
|
+
)
|
|
21
|
+
from activegraph.store.memory import InMemoryEventStore
|
|
22
|
+
from activegraph.store.serde import NonSerializableEventError
|
|
23
|
+
from activegraph.store.sqlite import SQLiteEventStore
|
|
24
|
+
from activegraph.store.url import InvalidStoreURL, StoreURL, open_store, parse_store_url
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CorruptedEventPayloadError",
|
|
28
|
+
"DuplicateEventError",
|
|
29
|
+
"EventNotFoundError",
|
|
30
|
+
"EventStore",
|
|
31
|
+
"InMemoryEventStore",
|
|
32
|
+
"InvalidStoreURL",
|
|
33
|
+
"NonSerializableEventError",
|
|
34
|
+
"RunRecord",
|
|
35
|
+
"SQLiteEventStore",
|
|
36
|
+
"SchemaVersionMismatch",
|
|
37
|
+
"StoreURL",
|
|
38
|
+
"open_store",
|
|
39
|
+
"parse_store_url",
|
|
40
|
+
"replay_into",
|
|
41
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""EventStore interface + run metadata. CONTRACT v0.5 #2 and #6.
|
|
2
|
+
|
|
3
|
+
An EventStore is a per-run, append-only view onto an event log. Multiple
|
|
4
|
+
runs may share a backing file (SQLite); the EventStore instance is scoped
|
|
5
|
+
to one `run_id` and only sees that run's events.
|
|
6
|
+
|
|
7
|
+
Methods are deliberately minimal — append, iterate, count, lookup,
|
|
8
|
+
truncate-after. No queries, no indexes beyond what the backend ships. This
|
|
9
|
+
is an event log, not a database.
|
|
10
|
+
|
|
11
|
+
The accompanying `RunRecord` is the canonical row in the `runs` table:
|
|
12
|
+
parent linkage for forks, an optional label, and the original goal/frame.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Iterable, Iterator, Optional, Protocol
|
|
19
|
+
|
|
20
|
+
from activegraph.core.event import Event
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RunRecord:
|
|
25
|
+
run_id: str
|
|
26
|
+
parent_run_id: Optional[str]
|
|
27
|
+
forked_at_event_id: Optional[str]
|
|
28
|
+
label: Optional[str]
|
|
29
|
+
created_at: str
|
|
30
|
+
goal: Optional[str]
|
|
31
|
+
frame_id: Optional[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EventStore(Protocol):
|
|
35
|
+
"""Append-only per-run event log. CONTRACT v0.5 #2."""
|
|
36
|
+
|
|
37
|
+
run_id: str
|
|
38
|
+
|
|
39
|
+
def append(self, event: Event) -> None: ...
|
|
40
|
+
|
|
41
|
+
def iter_events(
|
|
42
|
+
self,
|
|
43
|
+
after: Optional[str] = None,
|
|
44
|
+
until: Optional[str] = None,
|
|
45
|
+
) -> Iterator[Event]: ...
|
|
46
|
+
|
|
47
|
+
def get_event(self, event_id: str) -> Optional[Event]: ...
|
|
48
|
+
|
|
49
|
+
def count(self) -> int: ...
|
|
50
|
+
|
|
51
|
+
def truncate_after(self, event_id: str) -> None: ...
|
|
52
|
+
|
|
53
|
+
def close(self) -> None: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def replay_into(graph, events: Iterable[Event]) -> int:
|
|
57
|
+
"""Apply a stream of events to a Graph without firing listeners.
|
|
58
|
+
|
|
59
|
+
The single replay entry point — used by `Runtime.load` and `Runtime.fork`.
|
|
60
|
+
Returns the number of events replayed.
|
|
61
|
+
"""
|
|
62
|
+
n = 0
|
|
63
|
+
for ev in events:
|
|
64
|
+
graph._replay_event(ev) # noqa: SLF001 — internal seam by design
|
|
65
|
+
n += 1
|
|
66
|
+
return n
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""EventStore conformance suite. CONTRACT v0.8 #18.
|
|
2
|
+
|
|
3
|
+
A reusable pytest-compatible base class that exercises any EventStore
|
|
4
|
+
implementation against the protocol. Concrete subclasses override
|
|
5
|
+
``make_store(run_id)`` and ``cleanup()``; the tests run identically.
|
|
6
|
+
|
|
7
|
+
The InMemory, SQLite, and (with testcontainers) Postgres stores all run
|
|
8
|
+
through this suite. Any future store implementation gets free coverage
|
|
9
|
+
by subclassing.
|
|
10
|
+
|
|
11
|
+
The suite intentionally avoids pytest fixtures and yields plain
|
|
12
|
+
``unittest.TestCase``-style methods, so it works under any test runner.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from activegraph.core.event import Event
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EventStoreConformance(ABC):
|
|
26
|
+
"""Mix into a pytest test class to inherit the full suite.
|
|
27
|
+
|
|
28
|
+
Subclasses MUST implement:
|
|
29
|
+
|
|
30
|
+
def make_store(self, run_id: str) -> EventStore: ...
|
|
31
|
+
def cleanup(self) -> None: ...
|
|
32
|
+
|
|
33
|
+
Tests are method names starting with ``test_``. They are picked up
|
|
34
|
+
by pytest automatically when the concrete subclass is collected.
|
|
35
|
+
|
|
36
|
+
The base class is abstract (no ``test_`` methods callable on it)
|
|
37
|
+
by virtue of ``make_store`` raising on the abstract class. pytest
|
|
38
|
+
will still try to collect it; the ``__test__ = False`` attribute
|
|
39
|
+
prevents that.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
__test__ = False # do not collect the base; subclasses override.
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def make_store(self, run_id: str) -> Any:
|
|
46
|
+
"""Return a fresh EventStore for ``run_id``. Called per test."""
|
|
47
|
+
|
|
48
|
+
def cleanup(self) -> None:
|
|
49
|
+
"""Tear down any resources after a test. Default: no-op."""
|
|
50
|
+
|
|
51
|
+
# ---- helpers ----
|
|
52
|
+
|
|
53
|
+
def _ev(self, eid: str, type_: str = "object.created", payload: dict | None = None) -> Event:
|
|
54
|
+
return Event(
|
|
55
|
+
id=eid,
|
|
56
|
+
type=type_,
|
|
57
|
+
payload=payload or {"k": "v"},
|
|
58
|
+
actor="test",
|
|
59
|
+
frame_id=None,
|
|
60
|
+
caused_by=None,
|
|
61
|
+
timestamp="2026-01-01T00:00:00Z",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# ---- the suite ----
|
|
65
|
+
|
|
66
|
+
def test_append_then_iter_in_order(self) -> None:
|
|
67
|
+
try:
|
|
68
|
+
store = self.make_store("run_conformance_1")
|
|
69
|
+
for i in range(5):
|
|
70
|
+
store.append(self._ev(f"evt_{i}"))
|
|
71
|
+
ids = [e.id for e in store.iter_events()]
|
|
72
|
+
assert ids == [f"evt_{i}" for i in range(5)]
|
|
73
|
+
finally:
|
|
74
|
+
self.cleanup()
|
|
75
|
+
|
|
76
|
+
def test_count(self) -> None:
|
|
77
|
+
try:
|
|
78
|
+
store = self.make_store("run_conformance_2")
|
|
79
|
+
assert store.count() == 0
|
|
80
|
+
store.append(self._ev("evt_a"))
|
|
81
|
+
store.append(self._ev("evt_b"))
|
|
82
|
+
assert store.count() == 2
|
|
83
|
+
finally:
|
|
84
|
+
self.cleanup()
|
|
85
|
+
|
|
86
|
+
def test_get_event_known_and_unknown(self) -> None:
|
|
87
|
+
try:
|
|
88
|
+
store = self.make_store("run_conformance_3")
|
|
89
|
+
store.append(self._ev("evt_known"))
|
|
90
|
+
got = store.get_event("evt_known")
|
|
91
|
+
assert got is not None
|
|
92
|
+
assert got.id == "evt_known"
|
|
93
|
+
assert store.get_event("evt_missing") is None
|
|
94
|
+
finally:
|
|
95
|
+
self.cleanup()
|
|
96
|
+
|
|
97
|
+
def test_iter_after_skips_inclusive_boundary(self) -> None:
|
|
98
|
+
try:
|
|
99
|
+
store = self.make_store("run_conformance_4")
|
|
100
|
+
for i in range(4):
|
|
101
|
+
store.append(self._ev(f"evt_{i}"))
|
|
102
|
+
tail = [e.id for e in store.iter_events(after="evt_1")]
|
|
103
|
+
assert tail == ["evt_2", "evt_3"]
|
|
104
|
+
finally:
|
|
105
|
+
self.cleanup()
|
|
106
|
+
|
|
107
|
+
def test_iter_until_includes_boundary(self) -> None:
|
|
108
|
+
try:
|
|
109
|
+
store = self.make_store("run_conformance_5")
|
|
110
|
+
for i in range(4):
|
|
111
|
+
store.append(self._ev(f"evt_{i}"))
|
|
112
|
+
head = [e.id for e in store.iter_events(until="evt_2")]
|
|
113
|
+
assert head == ["evt_0", "evt_1", "evt_2"]
|
|
114
|
+
finally:
|
|
115
|
+
self.cleanup()
|
|
116
|
+
|
|
117
|
+
def test_truncate_after_drops_tail(self) -> None:
|
|
118
|
+
try:
|
|
119
|
+
store = self.make_store("run_conformance_6")
|
|
120
|
+
for i in range(5):
|
|
121
|
+
store.append(self._ev(f"evt_{i}"))
|
|
122
|
+
store.truncate_after("evt_2")
|
|
123
|
+
ids = [e.id for e in store.iter_events()]
|
|
124
|
+
assert ids == ["evt_0", "evt_1", "evt_2"]
|
|
125
|
+
finally:
|
|
126
|
+
self.cleanup()
|
|
127
|
+
|
|
128
|
+
def test_payload_round_trip_preserves_structure(self) -> None:
|
|
129
|
+
try:
|
|
130
|
+
store = self.make_store("run_conformance_7")
|
|
131
|
+
payload = {
|
|
132
|
+
"nested": {"k": [1, 2, {"a": "b"}]},
|
|
133
|
+
"unicode": "café — 🚀",
|
|
134
|
+
"empty": [],
|
|
135
|
+
"null_in_value": None,
|
|
136
|
+
}
|
|
137
|
+
store.append(self._ev("evt_payload", payload=payload))
|
|
138
|
+
got = store.get_event("evt_payload")
|
|
139
|
+
assert got is not None
|
|
140
|
+
assert got.payload == payload
|
|
141
|
+
finally:
|
|
142
|
+
self.cleanup()
|
|
143
|
+
|
|
144
|
+
def test_duplicate_id_in_same_run_is_rejected(self) -> None:
|
|
145
|
+
try:
|
|
146
|
+
store = self.make_store("run_conformance_8")
|
|
147
|
+
store.append(self._ev("evt_dup"))
|
|
148
|
+
with pytest.raises(Exception):
|
|
149
|
+
store.append(self._ev("evt_dup"))
|
|
150
|
+
finally:
|
|
151
|
+
self.cleanup()
|
|
152
|
+
|
|
153
|
+
def test_close_is_idempotent(self) -> None:
|
|
154
|
+
try:
|
|
155
|
+
store = self.make_store("run_conformance_9")
|
|
156
|
+
store.append(self._ev("evt_a"))
|
|
157
|
+
store.close()
|
|
158
|
+
# Closing twice should not raise.
|
|
159
|
+
store.close()
|
|
160
|
+
finally:
|
|
161
|
+
self.cleanup()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Storage-layer error leaves. v1.0 PR-C — StorageError category.
|
|
2
|
+
|
|
3
|
+
The classes here are the v1.0-format leaves under
|
|
4
|
+
:class:`activegraph.errors.StorageError`. The two pre-v1.0 storage
|
|
5
|
+
errors (:class:`NonSerializableEventError` in ``serde.py`` and
|
|
6
|
+
:class:`InvalidStoreURL` in ``url.py``) stay in their topic modules
|
|
7
|
+
and are re-parented in this PR; everything new lives here.
|
|
8
|
+
|
|
9
|
+
Multi-inheritance with Python builtins (KeyError, ValueError) is used
|
|
10
|
+
where existing user code conventionally catches the builtin —
|
|
11
|
+
``except KeyError`` around a store lookup, ``except ValueError`` around
|
|
12
|
+
event insertion. Preserves the catch site.
|
|
13
|
+
|
|
14
|
+
DB-driver errors (sqlite3.OperationalError, psycopg.OperationalError)
|
|
15
|
+
are NOT wrapped in this PR. The failure modes are driver-specific and
|
|
16
|
+
the recovery prose varies enough per mode (WAL contention, auth, host
|
|
17
|
+
unreachable, db missing, conn dropped) that a dedicated DB-error PR
|
|
18
|
+
will cover them with the right per-mode "Why:" and "How to fix:"
|
|
19
|
+
prose. Flagged in CONTRACT v1.0 PR-C section, not silently dropped.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from activegraph.errors import StorageError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SchemaVersionMismatch(StorageError):
|
|
28
|
+
"""The store's recorded ``schema_version`` doesn't match what this
|
|
29
|
+
activegraph build expects.
|
|
30
|
+
|
|
31
|
+
Fires on store open. The store file is intact; it was just written
|
|
32
|
+
by a different (older or newer) activegraph build. Recovery is
|
|
33
|
+
one of three things: upgrade activegraph, downgrade the store via
|
|
34
|
+
migration, or migrate the run to a fresh store with the current
|
|
35
|
+
build.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_doc_slug = "schema-version-mismatch"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EventNotFoundError(StorageError, KeyError):
|
|
42
|
+
"""An event id wasn't found in the run's event log.
|
|
43
|
+
|
|
44
|
+
Multi-inherits :class:`KeyError` so user code that does
|
|
45
|
+
``except KeyError`` around store lookups keeps working. Fires from
|
|
46
|
+
every ``store.get_event(event_id)`` and from the fork primitive
|
|
47
|
+
when ``--at-event`` names a missing id.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
_doc_slug = "event-not-found-error"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DuplicateEventError(StorageError, ValueError):
|
|
54
|
+
"""Two events with the same id were appended to the same run.
|
|
55
|
+
|
|
56
|
+
Multi-inherits :class:`ValueError` for back-compat with user code
|
|
57
|
+
catching ValueError around appends. Fires only on programmer error:
|
|
58
|
+
the runtime's id generator is monotonic so duplicates shouldn't
|
|
59
|
+
arise in normal use. Common cause: hand-constructing events with
|
|
60
|
+
fixed ids in a test fixture.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
_doc_slug = "duplicate-event-error"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CorruptedEventPayloadError(StorageError):
|
|
67
|
+
"""A stored event payload couldn't be decoded as JSON.
|
|
68
|
+
|
|
69
|
+
Fires at load-time when a row's payload column contains invalid
|
|
70
|
+
JSON. Distinct from :class:`NonSerializableEventError`, which fires
|
|
71
|
+
at emit-time when a Python value can't be encoded to JSON.
|
|
72
|
+
Corruption-on-load means the bytes on disk don't parse — a
|
|
73
|
+
different failure mode requiring a different recovery.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
_doc_slug = "corrupted-event-payload-error"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"SchemaVersionMismatch",
|
|
81
|
+
"EventNotFoundError",
|
|
82
|
+
"DuplicateEventError",
|
|
83
|
+
"CorruptedEventPayloadError",
|
|
84
|
+
]
|