keel-context 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
keel/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """Keel: a context layer for AI agents in vertical SaaS.
2
+
3
+ Domain events in, a bitemporal context graph per tenant out,
4
+ shared by every agent through five tools.
5
+ """
6
+
7
+ from keel.cartridge import Cartridge, load_cartridge
8
+ from keel.envelope import Event
9
+ from keel.handlers import HandlerRegistry
10
+ from keel.ingest import Ingestor
11
+ from keel.scopes import Scope
12
+ from keel.store.memory import MemoryStore
13
+ from keel.store.sqlite import SqliteStore
14
+ from keel.tools import Tools
15
+
16
+ __all__ = [
17
+ "Cartridge",
18
+ "Event",
19
+ "HandlerRegistry",
20
+ "Ingestor",
21
+ "MemoryStore",
22
+ "Scope",
23
+ "SqliteStore",
24
+ "Tools",
25
+ "load_cartridge",
26
+ ]
27
+
28
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Event adapters: anything that yields `Event`s.
2
+
3
+ Shipped: JsonlAdapter (keel.ingest) for development, PostgresTableAdapter
4
+ (table + polling with a cursor), webhook `build_app` (FastAPI, push).
5
+ Planned: KafkaAdapter, SQSAdapter, Postgres CDC.
6
+ Each adapter only produces envelopes; it never understands payloads.
7
+ """
@@ -0,0 +1,108 @@
1
+ """Table + polling adapter: your SaaS writes envelopes to an outbox table, Keel polls it.
2
+
3
+ Optional extra for the driver: `uv sync --extra postgres` (psycopg). Any DB-API 2.0
4
+ connection works; the tests run it on sqlite3.
5
+
6
+ Expected table (names are configurable):
7
+
8
+ CREATE TABLE keel_events (
9
+ seq bigserial PRIMARY KEY, -- the cursor: assigned at insert, read in order
10
+ id text NOT NULL,
11
+ tenant text NOT NULL,
12
+ type text NOT NULL,
13
+ subject text NOT NULL,
14
+ occurred_at timestamptz NOT NULL,
15
+ recorded_at timestamptz NOT NULL DEFAULT now(),
16
+ source text NOT NULL,
17
+ payload jsonb NOT NULL DEFAULT '{}'
18
+ );
19
+
20
+ Usage:
21
+
22
+ import psycopg
23
+ from keel.adapters.postgres import PostgresTableAdapter
24
+
25
+ adapter = PostgresTableAdapter(lambda: psycopg.connect(DSN), cursor_path=".keel/cursor")
26
+ ingestor.ingest(adapter) # one pass over everything after the cursor
27
+ ingestor.ingest(adapter.follow(interval=5)) # or poll forever
28
+
29
+ The cursor is a text file holding the last `seq` the consumer took, so a restart
30
+ continues where it stopped. Delivery is at-least-once: the cursor advances after
31
+ the consumer has taken an event, so a crash mid-event re-delivers it.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import time
38
+ from collections.abc import Callable, Iterator
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ from keel.envelope import Event
43
+
44
+ Connect = Callable[[], Any] # returns a DB-API 2.0 connection
45
+
46
+
47
+ class PostgresTableAdapter:
48
+ def __init__(
49
+ self,
50
+ connect: Connect,
51
+ table: str = "keel_events",
52
+ cursor_path: str | Path = ".keel/cursor",
53
+ cursor_column: str = "seq",
54
+ batch_size: int = 500,
55
+ placeholder: str = "%s", # psycopg uses %s; sqlite3 uses ?
56
+ ) -> None:
57
+ self.connect = connect
58
+ self.cursor_path = Path(cursor_path)
59
+ self.cursor_column = cursor_column
60
+ self.batch_size = batch_size
61
+ # ponytail: a serial cursor can skip a row that commits late under concurrent
62
+ # writers; use one writer per table, or a recorded_at cursor with a lag, if that bites.
63
+ self.sql = (
64
+ f"SELECT * FROM {table} WHERE {cursor_column} > {placeholder} "
65
+ f"ORDER BY {cursor_column} LIMIT {placeholder}"
66
+ )
67
+
68
+ @property
69
+ def position(self) -> int:
70
+ try:
71
+ return int(self.cursor_path.read_text())
72
+ except FileNotFoundError:
73
+ return 0
74
+
75
+ def _save(self, seq: int) -> None:
76
+ self.cursor_path.parent.mkdir(parents=True, exist_ok=True)
77
+ self.cursor_path.write_text(str(seq))
78
+
79
+ def __iter__(self) -> Iterator[Event]:
80
+ """One pass: every row after the cursor, in order, then stop."""
81
+ conn = self.connect()
82
+ try:
83
+ while True:
84
+ cur = conn.cursor()
85
+ cur.execute(self.sql, (self.position, self.batch_size))
86
+ cols = [d[0] for d in cur.description]
87
+ rows = cur.fetchall()
88
+ for row in rows:
89
+ rec = dict(zip(cols, row))
90
+ seq = rec.pop(self.cursor_column)
91
+ if isinstance(rec.get("payload"), str):
92
+ rec["payload"] = json.loads(rec["payload"])
93
+ yield Event.model_validate(rec)
94
+ self._save(seq) # runs once the consumer asks for the next event
95
+ if len(rows) < self.batch_size:
96
+ return
97
+ finally:
98
+ conn.close()
99
+
100
+ def follow(self, interval: float = 5.0) -> Iterator[Event]:
101
+ """Poll forever. `ingestor.ingest(adapter.follow())` never returns."""
102
+ while True:
103
+ n = 0
104
+ for ev in self:
105
+ n += 1
106
+ yield ev
107
+ if n == 0:
108
+ time.sleep(interval)
@@ -0,0 +1,38 @@
1
+ """Webhook adapter: HTTP in, envelope validated, ingested. Optional extra: `uv sync --extra webhook`.
2
+
3
+ from keel.adapters.webhook import build_app
4
+ app = build_app(ingestor, token="shared-secret") # then: uvicorn yourmodule:app
5
+
6
+ POST /events with one envelope or a list of them and `Authorization: Bearer <token>`.
7
+ Unlike the pull adapters, this one pushes into the ingestor: the sender drives the loop.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hmac
13
+ from typing import Any
14
+
15
+ from keel.envelope import Event
16
+ from keel.ingest import Ingestor
17
+
18
+
19
+ def build_app(ingestor: Ingestor, token: str | None = None, strict: bool = False):
20
+ """`token=None` accepts any caller; use it only on localhost."""
21
+ try:
22
+ from fastapi import Depends, FastAPI, Header, HTTPException
23
+ except ImportError as exc:
24
+ raise SystemExit("Webhook adapter needs the optional dependency: uv sync --extra webhook") from exc
25
+
26
+ app = FastAPI(title=f"keel:{ingestor.cartridge.name}")
27
+
28
+ def auth(authorization: str | None = Header(default=None)) -> None:
29
+ if token and not hmac.compare_digest(authorization or "", f"Bearer {token}"):
30
+ raise HTTPException(401, "bad or missing bearer token")
31
+
32
+ @app.post("/events", dependencies=[Depends(auth)])
33
+ def events(body: list[Event] | Event) -> dict[str, Any]:
34
+ batch = body if isinstance(body, list) else [body]
35
+ report = ingestor.ingest(batch, strict=strict)
36
+ return {"processed": report.processed, "unhandled": report.unhandled, "errors": report.errors}
37
+
38
+ return app
keel/cartridge.py ADDED
@@ -0,0 +1,151 @@
1
+ """Cartridge loader.
2
+
3
+ A cartridge is a folder:
4
+
5
+ cartridges/fleet/
6
+ cartridge.yaml ontology, event catalog, semantic rules, memory policies
7
+ handlers.py a `registry = HandlerRegistry()` with one handler per event type
8
+
9
+ The core loads the YAML, validates that every cataloged event has a
10
+ handler and every relation uses declared entity kinds, and hands the
11
+ registry to the ingestor. That is the whole contract between core and
12
+ domain. If writing your second cartridge makes you touch the core, some
13
+ domain leaked in; pull it back out.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib.util
19
+ from dataclasses import dataclass, field
20
+ from datetime import timedelta
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import yaml
25
+
26
+ from keel.envelope import Event, parse_iso
27
+ from keel.handlers import HandlerRegistry
28
+ from keel.rules import problems as rule_problems
29
+ from keel.scopes import MemoryPolicy, Scope
30
+
31
+
32
+ @dataclass
33
+ class Cartridge:
34
+ name: str
35
+ path: Path
36
+ entities: dict[str, dict[str, Any]] # kind -> {description, ref?}
37
+ relations: dict[str, dict[str, str]] # rel -> {from, to}
38
+ events: dict[str, dict[str, Any]] # type -> {subject, owner, payload: {...}}
39
+ rules: dict[str, Any] = field(default_factory=dict) # semantic definitions, versioned
40
+ policy: MemoryPolicy = field(default_factory=MemoryPolicy.default)
41
+ registry: HandlerRegistry = field(default_factory=HandlerRegistry)
42
+
43
+ def validate(self) -> list[str]:
44
+ problems: list[str] = []
45
+ for rel, spec in self.relations.items():
46
+ for side in ("from", "to"):
47
+ kind = spec.get(side)
48
+ if kind not in self.entities:
49
+ problems.append(f"relation '{rel}' {side}='{kind}' is not a declared entity kind")
50
+ for etype, spec in self.events.items():
51
+ if spec.get("subject") not in self.entities:
52
+ problems.append(f"event '{etype}' subject kind '{spec.get('subject')}' is not declared")
53
+ if etype not in self.registry.types():
54
+ problems.append(f"event '{etype}' has no handler in handlers.py")
55
+ for etype in self.registry.types() - set(self.events):
56
+ problems.append(f"handler for '{etype}' exists but the event is not in the catalog")
57
+ for name, rule in self.rules.items():
58
+ problems += rule_problems(name, rule)
59
+ return problems
60
+
61
+ def check_event(self, event: Event) -> list[str]:
62
+ """One event against the catalog: known type, right subject kind, declared payload fields
63
+ present with the declared type. Structural only: keys and types, never values."""
64
+ spec = self.events.get(event.type)
65
+ if spec is None:
66
+ return [f"unknown event type '{event.type}'"]
67
+ out = []
68
+ if event.subject_kind != spec.get("subject"):
69
+ out.append(f"'{event.type}' subject '{event.subject}' is not a '{spec.get('subject')}'")
70
+ for name, hint in (spec.get("payload") or {}).items():
71
+ kinds = {k.strip() for k in str(hint).split("|")}
72
+ if name not in event.payload:
73
+ if "null" not in kinds:
74
+ out.append(f"'{event.type}' payload is missing '{name}' ({hint})")
75
+ continue
76
+ value = event.payload[name]
77
+ if value is None and "null" in kinds:
78
+ continue
79
+ if not any(_is(value, k) for k in kinds - {"null"}):
80
+ out.append(f"'{event.type}' payload '{name}' should be {hint}, got {type(value).__name__}")
81
+ return out
82
+
83
+
84
+ def _is(value: Any, hint: str) -> bool:
85
+ """Does a payload value match a catalog type hint? Hints the core does not know are documentation."""
86
+ if hint == "string":
87
+ return isinstance(value, str)
88
+ if hint == "bool":
89
+ return isinstance(value, bool)
90
+ if hint == "int":
91
+ return isinstance(value, int) and not isinstance(value, bool)
92
+ if hint == "number":
93
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
94
+ if hint in ("date", "datetime"):
95
+ try:
96
+ parse_iso(value)
97
+ except ValueError:
98
+ return False
99
+ return True
100
+ return True
101
+
102
+
103
+ def _parse_ttl(v: Any) -> timedelta | None:
104
+ if v in (None, "never", "forever"):
105
+ return None
106
+ if isinstance(v, (int, float)):
107
+ return timedelta(days=float(v))
108
+ s = str(v).strip()
109
+ unit = s[-1]
110
+ n = float(s[:-1])
111
+ return {"d": timedelta(days=n), "h": timedelta(hours=n), "m": timedelta(minutes=n)}[unit]
112
+
113
+
114
+ def _policy_from(raw: dict[str, Any] | None) -> MemoryPolicy:
115
+ base = MemoryPolicy.default()
116
+ if not raw:
117
+ return base
118
+ ttl = dict(base.ttl)
119
+ for k, v in (raw.get("ttl") or {}).items():
120
+ ttl[Scope(k)] = _parse_ttl(v)
121
+ req = {Scope(s) for s in raw.get("requires_evidence", [s.value for s in base.requires_evidence])}
122
+ return MemoryPolicy(ttl=ttl, requires_evidence=req)
123
+
124
+
125
+ def _load_registry(path: Path) -> HandlerRegistry:
126
+ handlers_py = path / "handlers.py"
127
+ if not handlers_py.exists():
128
+ return HandlerRegistry()
129
+ spec = importlib.util.spec_from_file_location(f"keel_cartridge_{path.name}_handlers", handlers_py)
130
+ assert spec and spec.loader
131
+ mod = importlib.util.module_from_spec(spec)
132
+ spec.loader.exec_module(mod)
133
+ reg = getattr(mod, "registry", None)
134
+ if not isinstance(reg, HandlerRegistry):
135
+ raise TypeError(f"{handlers_py} must define `registry = HandlerRegistry()`")
136
+ return reg
137
+
138
+
139
+ def load_cartridge(path: str | Path) -> Cartridge:
140
+ path = Path(path)
141
+ raw = yaml.safe_load((path / "cartridge.yaml").read_text(encoding="utf-8")) or {}
142
+ return Cartridge(
143
+ name=raw.get("name", path.name),
144
+ path=path,
145
+ entities=raw.get("entities") or {},
146
+ relations=raw.get("relations") or {},
147
+ events=raw.get("events") or {},
148
+ rules=raw.get("rules") or {},
149
+ policy=_policy_from(raw.get("memory")),
150
+ registry=_load_registry(path),
151
+ )
keel/cli.py ADDED
@@ -0,0 +1,182 @@
1
+ """keel CLI: init, validate, ingest, replay, expire, export, context, serve."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from keel.cartridge import load_cartridge
11
+ from keel.envelope import Event
12
+ from keel.ingest import Ingestor, IngestReport, JsonlAdapter
13
+ from keel.store.memory import MemoryStore
14
+ from keel.store.sqlite import SqliteStore
15
+ from keel.tools import Tools
16
+
17
+ app = typer.Typer(help="Keel: a context layer for AI agents in vertical SaaS.", no_args_is_help=True)
18
+
19
+ DB = typer.Option(None, "--db", help="SQLite file for a durable graph. Without it the graph lives in memory and ends with the command.")
20
+
21
+ CARTRIDGE_TEMPLATE = """name: {name}
22
+
23
+ entities:
24
+ account:
25
+ description: The thing your product is about. Rename it.
26
+
27
+ relations: {{}}
28
+
29
+ events:
30
+ account.created:
31
+ subject: account
32
+ owner: core
33
+ payload:
34
+ id: string
35
+ name: string
36
+
37
+ rules: {{}}
38
+
39
+ memory:
40
+ ttl:
41
+ task: 14d
42
+ agent: 1d
43
+ requires_evidence: [global, tenant]
44
+ """
45
+
46
+ HANDLERS_TEMPLATE = '''"""Handlers for the {name} cartridge. One per event type, a few lines each."""
47
+
48
+ from keel.handlers import HandlerRegistry
49
+
50
+ registry = HandlerRegistry()
51
+
52
+
53
+ @registry.on("account.created")
54
+ def account_created(event, store):
55
+ p = event.payload
56
+ store.upsert_entity(event.tenant, "account", p["id"], ref=event.source, name=p.get("name"))
57
+ '''
58
+
59
+
60
+ @app.command()
61
+ def init(path: Path = typer.Argument(..., help="Folder for the new cartridge, e.g. cartridges/clinics")):
62
+ """Scaffold a new cartridge."""
63
+ path.mkdir(parents=True, exist_ok=False)
64
+ (path / "cartridge.yaml").write_text(CARTRIDGE_TEMPLATE.format(name=path.name), encoding="utf-8")
65
+ (path / "handlers.py").write_text(HANDLERS_TEMPLATE.format(name=path.name), encoding="utf-8")
66
+ typer.echo(f"created {path}/cartridge.yaml and {path}/handlers.py")
67
+
68
+
69
+ @app.command()
70
+ def validate(
71
+ cartridge: Path,
72
+ events: Path | None = typer.Option(None, help="Also check a .jsonl stream: unknown types, wrong subject kinds, payload shape."),
73
+ ):
74
+ """Check that the catalog, the ontology and the handlers agree; with --events, that a stream fits the catalog."""
75
+ c = load_cartridge(cartridge)
76
+ problems = c.validate()
77
+ checked = 0
78
+ if events:
79
+ for n, line in enumerate(events.read_text(encoding="utf-8").splitlines(), 1):
80
+ if not line.strip() or line.startswith("#"):
81
+ continue
82
+ try:
83
+ ev = Event.model_validate(json.loads(line))
84
+ except ValueError as exc: # json and pydantic errors both subclass ValueError
85
+ errs = getattr(exc, "errors", None) # pydantic: first error, with its field
86
+ detail = f"{'.'.join(map(str, errs()[0]['loc']))}: {errs()[0]['msg']}" if errs else str(exc)
87
+ problems.append(f"line {n}: bad envelope: {detail}")
88
+ continue
89
+ checked += 1
90
+ problems += [f"line {n}: {p}" for p in c.check_event(ev)]
91
+ if problems:
92
+ for p in problems:
93
+ typer.echo(f" - {p}")
94
+ raise typer.Exit(code=1)
95
+ typer.echo(f"{c.name}: {len(c.entities)} entity kinds, {len(c.relations)} relations, {len(c.events)} events, all handled")
96
+ if events:
97
+ typer.echo(f"{events}: {checked} events match the catalog")
98
+
99
+
100
+ def _run(cartridge: Path, events: Path | None, strict: bool = False, db: Path | None = None):
101
+ c = load_cartridge(cartridge)
102
+ store = SqliteStore(db) if db else MemoryStore()
103
+ ing = Ingestor(c, store)
104
+ report = ing.ingest(JsonlAdapter(events), strict=strict) if events else IngestReport()
105
+ return c, store, ing, report
106
+
107
+
108
+ @app.command()
109
+ def ingest(cartridge: Path, events: Path, strict: bool = False, db: Path | None = DB):
110
+ """Ingest a .jsonl event stream into the graph (in memory, or the --db file) and report."""
111
+ _, _, _, report = _run(cartridge, events, strict, db)
112
+ typer.echo(f"processed={report.processed} unhandled={len(report.unhandled)} errors={len(report.errors)}")
113
+ for eid, err in report.errors:
114
+ typer.echo(f" {eid}: {err}")
115
+
116
+
117
+ @app.command()
118
+ def replay(cartridge: Path, events: Path, tenant: str, db: Path | None = DB):
119
+ """Rebuild one tenant's subgraph from the event log (recovery path). With --db, the file is wiped for that tenant first."""
120
+ _, _, ing, _ = _run(cartridge, None, db=db)
121
+ report = ing.replay(tenant, events=JsonlAdapter(events))
122
+ typer.echo(f"replayed tenant={tenant} processed={report.processed}")
123
+
124
+
125
+ @app.command()
126
+ def expire(tenant: str, db: Path = typer.Option(..., "--db", help="The durable graph to clean.")):
127
+ """Drop task/agent memory past its expiry for one tenant. Reads already ignore expired facts; run this from cron to reclaim space."""
128
+ n = SqliteStore(db).expire(tenant)
129
+ typer.echo(f"expired tenant={tenant} dropped={n}")
130
+
131
+
132
+ @app.command()
133
+ def export(
134
+ tenant: str,
135
+ db: Path = typer.Option(..., "--db", help="The durable graph to export from."),
136
+ out: Path | None = typer.Option(None, "--out", help="Write here instead of stdout."),
137
+ ):
138
+ """Audit trail for one tenant as JSONL: every entity, relation and fact, closed ones included, with provenance, scope, evidence and who corrected what."""
139
+ from dataclasses import asdict
140
+
141
+ from keel.tools import _ser
142
+
143
+ lines = (json.dumps({"record": type(o).__name__.lower(), **_ser(asdict(o))}, ensure_ascii=False) for o in SqliteStore(db).dump(tenant))
144
+ if out:
145
+ n = 0
146
+ with out.open("w", encoding="utf-8") as fh:
147
+ for line in lines:
148
+ fh.write(line + "\n")
149
+ n += 1
150
+ typer.echo(f"exported tenant={tenant} records={n} to {out}")
151
+ else:
152
+ for line in lines:
153
+ typer.echo(line)
154
+
155
+
156
+ @app.command()
157
+ def context(cartridge: Path, events: Path, subject: str, tenant: str | None = None):
158
+ """Ingest, then print what the `context` tool returns for a subject."""
159
+ c, store, ing, _ = _run(cartridge, events)
160
+ tenant = tenant or next(iter({e.tenant for e in ing.log}), "")
161
+ typer.echo(json.dumps(Tools(c, store, tenant).context(subject), indent=2, ensure_ascii=False))
162
+
163
+
164
+ @app.command()
165
+ def serve(
166
+ cartridge: Path,
167
+ tenant: str = typer.Option(..., help="The end-customer this server is bound to."),
168
+ events: Path | None = None,
169
+ db: Path | None = DB,
170
+ ):
171
+ """Serve the five tools over MCP (stdio). Requires `uv sync --extra mcp`. --events fills an in-memory graph; --db serves a durable one."""
172
+ from keel.mcp_server import build_server
173
+
174
+ c = load_cartridge(cartridge)
175
+ store = SqliteStore(db) if db else MemoryStore()
176
+ if events:
177
+ Ingestor(c, store).ingest(JsonlAdapter(events))
178
+ build_server(Tools(c, store, tenant)).run()
179
+
180
+
181
+ if __name__ == "__main__":
182
+ app()
keel/envelope.py ADDED
@@ -0,0 +1,72 @@
1
+ """The standard event envelope.
2
+
3
+ Every event, from any source, arrives in this shape. The core never reads
4
+ `payload`; it only routes on `type` and keys on `tenant` and `subject`.
5
+ A cartridge's handlers are the only code that understands the payload.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import datetime, timezone
12
+ from hashlib import sha256
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, Field, field_validator, model_validator
16
+
17
+
18
+ def utcnow() -> datetime:
19
+ return datetime.now(timezone.utc)
20
+
21
+
22
+ def parse_iso(value: Any) -> datetime:
23
+ """ISO 8601 date or datetime to an aware UTC datetime. Accepts a trailing Z on Python 3.10 too."""
24
+ if isinstance(value, datetime):
25
+ d = value
26
+ else:
27
+ d = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
28
+ return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
29
+
30
+
31
+ class Event(BaseModel):
32
+ """A domain fact with time, from one of the SaaS's systems."""
33
+
34
+ id: str = Field(default="", description="The source's own id when it has one; else a content hash, so replay yields the same ids.")
35
+ tenant: str = Field(..., description="The end-customer this fact belongs to (the tenant of the tenant).")
36
+ type: str = Field(..., description="Dotted event type from the cartridge catalog, e.g. 'inspection.created'.")
37
+ subject: str = Field(..., description="Entity reference '<kind>:<id>' the fact is about, e.g. 'unit:MXK-4821'.")
38
+ occurred_at: datetime = Field(..., description="When it happened in the world (valid time).")
39
+ recorded_at: datetime = Field(default_factory=utcnow, description="When Keel learned it (transaction time).")
40
+ source: str = Field(..., description="System of record, e.g. 'vec.inspections' or 'webhook:telematics'.")
41
+ payload: dict[str, Any] = Field(default_factory=dict)
42
+
43
+ @model_validator(mode="before")
44
+ @classmethod
45
+ def _content_hash_id(cls, data: Any) -> Any:
46
+ if isinstance(data, dict) and not data.get("id"):
47
+ fields = ("tenant", "type", "subject", "occurred_at", "source", "payload")
48
+ key = json.dumps([data.get(k) for k in fields], sort_keys=True, default=str)
49
+ data = {**data, "id": sha256(key.encode()).hexdigest()[:32]}
50
+ return data
51
+
52
+ @field_validator("subject")
53
+ @classmethod
54
+ def _subject_shape(cls, v: str) -> str:
55
+ if ":" not in v:
56
+ raise ValueError("subject must look like '<kind>:<id>'")
57
+ return v
58
+
59
+ @field_validator("occurred_at", "recorded_at")
60
+ @classmethod
61
+ def _aware(cls, v: datetime) -> datetime:
62
+ if v.tzinfo is None:
63
+ return v.replace(tzinfo=timezone.utc)
64
+ return v
65
+
66
+ @property
67
+ def subject_kind(self) -> str:
68
+ return self.subject.split(":", 1)[0]
69
+
70
+ @property
71
+ def subject_id(self) -> str:
72
+ return self.subject.split(":", 1)[1]
keel/handlers.py ADDED
@@ -0,0 +1,56 @@
1
+ """Handler registry.
2
+
3
+ A handler is the only code that understands an event's payload. It maps
4
+ the payload to store primitives in a few lines. The core routes on
5
+ `event.type`; it never looks inside.
6
+
7
+ registry = HandlerRegistry()
8
+
9
+ @registry.on("inspection.created")
10
+ def inspection_created(event, store):
11
+ store.upsert_entity(event.tenant, "inspection", event.payload["id"], ref=event.source)
12
+ store.link(event.tenant, event.subject, "has_inspection", f"inspection:{event.payload['id']}", event.occurred_at)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+
19
+ from keel.envelope import Event
20
+ from keel.store.base import Store
21
+
22
+ Handler = Callable[[Event, Store], None]
23
+
24
+
25
+ class UnhandledEvent(Exception):
26
+ pass
27
+
28
+
29
+ class HandlerRegistry:
30
+ def __init__(self) -> None:
31
+ self._handlers: dict[str, list[Handler]] = {}
32
+
33
+ def on(self, event_type: str) -> Callable[[Handler], Handler]:
34
+ def deco(fn: Handler) -> Handler:
35
+ self._handlers.setdefault(event_type, []).append(fn)
36
+ return fn
37
+
38
+ return deco
39
+
40
+ def register(self, event_type: str, fn: Handler) -> None:
41
+ self._handlers.setdefault(event_type, []).append(fn)
42
+
43
+ def types(self) -> set[str]:
44
+ return set(self._handlers)
45
+
46
+ def dispatch(self, event: Event, store: Store) -> int:
47
+ handlers = self._handlers.get(event.type)
48
+ if not handlers:
49
+ raise UnhandledEvent(f"no handler for event type '{event.type}'")
50
+ for h in handlers:
51
+ h(event, store)
52
+ return len(handlers)
53
+
54
+ def merge(self, other: HandlerRegistry) -> None:
55
+ for t, hs in other._handlers.items():
56
+ self._handlers.setdefault(t, []).extend(hs)