proactive-gate 0.2.1__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.
@@ -0,0 +1,134 @@
1
+ """Runs the language-neutral fixtures under ``spec/fixtures`` against ``Gate`` or ``AsyncGate``."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from collections.abc import Iterator, Mapping
7
+ from dataclasses import asdict
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .gate import AsyncGate, Gate
12
+ from .stores import AsyncMemoryStore, MemoryStore
13
+ from .types import Candidate, Decision, EvaluateInput, UserState, iso_z, to_datetime
14
+
15
+
16
+ def load_fixtures(directory: str | Path) -> Iterator[dict[str, Any]]:
17
+ for path in sorted(Path(directory).rglob("*.json")):
18
+ with path.open(encoding="utf-8") as handle:
19
+ fixture: dict[str, Any] = json.load(handle)
20
+ yield fixture
21
+
22
+
23
+ def read_skips(path: str | Path) -> dict[str, str]:
24
+ skips: dict[str, str] = {}
25
+ try:
26
+ text = Path(path).read_text(encoding="utf-8")
27
+ except FileNotFoundError:
28
+ return skips
29
+ for line in text.splitlines():
30
+ line = line.strip()
31
+ if not line or line.startswith("#"):
32
+ continue
33
+ name, _, reason = line.partition("#")
34
+ skips[name.strip()] = reason.strip()
35
+ return skips
36
+
37
+
38
+ def _input(test: Mapping[str, Any]) -> EvaluateInput:
39
+ raw = test["input"]
40
+ now = to_datetime(raw["now"])
41
+ if now is None:
42
+ raise ValueError(f"bad now {raw['now']!r}")
43
+ return EvaluateInput(UserState.from_dict(raw["user"]), Candidate.from_dict(raw["candidate"]), now)
44
+
45
+
46
+ def _compare(failures: list[str], at: str, decision: Decision, expect: Mapping[str, Any]) -> None:
47
+ def check(field: str, actual: object, expected: object) -> None:
48
+ if json.dumps(actual, sort_keys=True) != json.dumps(expected, sort_keys=True):
49
+ failures.append(f"{at}: {field} expected {json.dumps(expected)}, got {json.dumps(actual)}")
50
+
51
+ check("allowed", decision.allowed, expect["allowed"])
52
+ check("trace", [t.id for t in decision.trace], expect["trace"])
53
+ check("rejectedBy", decision.rejected_by, expect.get("rejectedBy"))
54
+ check("deferredBy", decision.deferred_by, expect.get("deferredBy"))
55
+ check("retryAt", iso_z(decision.retry_at) if decision.retry_at else None, expect.get("retryAt"))
56
+ if "surfaces" in expect:
57
+ check("surfaces", list(decision.surfaces), expect["surfaces"])
58
+ check("deliverAt", iso_z(decision.deliver_at) if decision.deliver_at else None, expect.get("deliverAt"))
59
+ if "shadowed" in expect:
60
+ check("shadowed", list(decision.shadowed), expect["shadowed"])
61
+ if "nearLimit" in expect:
62
+ check("nearLimit", [asdict(n) for n in decision.near_limit], expect["nearLimit"])
63
+ pattern = expect.get("reason_pattern")
64
+ if pattern and not (decision.reason and re.search(pattern, decision.reason)):
65
+ failures.append(f"{at}: reason {json.dumps(decision.reason)} does not match /{pattern}/")
66
+
67
+
68
+ def run_fixture(fixture: Mapping[str, Any]) -> list[str]:
69
+ """Synchronous gate. Returns the mismatches; empty means the fixture conforms."""
70
+ failures: list[str] = []
71
+ store = MemoryStore()
72
+ prefix = fixture["policy"].get("keyPrefix", "pg:")
73
+ for key, value in (fixture.get("store_seed") or {}).items():
74
+ store.set(prefix + key, value)
75
+ try:
76
+ gate = Gate.from_policy(fixture["policy"], store)
77
+ except ValueError as error:
78
+ return _policy_error(fixture, failures, str(error))
79
+ for i, test in enumerate(fixture["tests"]):
80
+ at = f"{fixture['name']} [{i}] {test['description']}"
81
+ inp = _input(test)
82
+ decision = gate.evaluate(inp)
83
+ expect = test["expect"]
84
+ _compare(failures, at, decision, expect)
85
+ if test.get("commit"):
86
+ committed = gate.commit(decision, inp)
87
+ if "commit" in expect and committed != expect["commit"]:
88
+ failures.append(f"{at}: commit expected {expect['commit']}, got {committed}")
89
+ for key, value in (expect.get("store_after") or {}).items():
90
+ actual = store.get(prefix + key)
91
+ if actual != value:
92
+ failures.append(f"{at}: store {key} expected {json.dumps(value)}, got {json.dumps(actual)}")
93
+ return failures
94
+
95
+
96
+ async def run_fixture_async(fixture: Mapping[str, Any]) -> list[str]:
97
+ """The same fixture through ``AsyncGate`` and ``AsyncMemoryStore``."""
98
+ failures: list[str] = []
99
+ store = AsyncMemoryStore()
100
+ prefix = fixture["policy"].get("keyPrefix", "pg:")
101
+ for key, value in (fixture.get("store_seed") or {}).items():
102
+ await store.set(prefix + key, value)
103
+ try:
104
+ gate = AsyncGate.from_policy(fixture["policy"], store)
105
+ except ValueError as error:
106
+ return _policy_error(fixture, failures, str(error))
107
+ for i, test in enumerate(fixture["tests"]):
108
+ at = f"{fixture['name']} [{i}] {test['description']}"
109
+ inp = _input(test)
110
+ decision = await gate.evaluate(inp)
111
+ expect = test["expect"]
112
+ _compare(failures, at, decision, expect)
113
+ if test.get("commit"):
114
+ committed = await gate.commit(decision, inp)
115
+ if "commit" in expect and committed != expect["commit"]:
116
+ failures.append(f"{at}: commit expected {expect['commit']}, got {committed}")
117
+ for key, value in (expect.get("store_after") or {}).items():
118
+ actual = await store.get(prefix + key)
119
+ if actual != value:
120
+ failures.append(f"{at}: store {key} expected {json.dumps(value)}, got {json.dumps(actual)}")
121
+ return failures
122
+
123
+
124
+ def _policy_error(fixture: Mapping[str, Any], failures: list[str], message: str) -> list[str]:
125
+ """A fixture whose policy must fail to compile says so through a test whose expect carries ``policy_error``."""
126
+ for i, test in enumerate(fixture["tests"]):
127
+ pattern = test["expect"].get("reason_pattern")
128
+ if pattern and re.search(pattern, message):
129
+ continue
130
+ failures.append(f"{fixture['name']} [{i}] {test['description']}: policy failed to compile: {message}")
131
+ return failures
132
+
133
+
134
+ __all__ = ["load_fixtures", "read_skips", "run_fixture", "run_fixture_async"]
proactive_gate/gate.py ADDED
@@ -0,0 +1,395 @@
1
+ """The decision loop exists once, as a sans-IO ``Evaluation``. ``Gate`` and
2
+ ``AsyncGate`` only fetch keys and feed values; every rule about ordering,
3
+ shadow mode, deferral, hooks and idempotent commit lives here."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import time
8
+ from collections.abc import Callable, Iterable, Mapping, Sequence
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime
11
+ from typing import Any
12
+
13
+ from .checks import Budget, Check, OnlyWhen, budget_key, dismissal_key
14
+ from .clock import DAY_SECONDS
15
+ from .stores import AsyncStore, MemoryStore, Store
16
+ from .types import (
17
+ UTC,
18
+ Candidate,
19
+ ConsumePlan,
20
+ Context,
21
+ Decision,
22
+ EvaluateInput,
23
+ NearLimitNote,
24
+ Outcome,
25
+ OutcomeEvent,
26
+ StoreErrorMode,
27
+ TraceEntry,
28
+ UserState,
29
+ epoch_ms,
30
+ iso_z,
31
+ surfaces_for,
32
+ )
33
+
34
+ COMMIT_TTL = 2 * DAY_SECONDS
35
+ Values = Mapping[str, str | None]
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class Hooks:
40
+ """Observation points. Hooks never change a decision; a raising hook is reported to ``error`` and ignored."""
41
+
42
+ before: Callable[[Context, Check], None] | None = None
43
+ after: Callable[[Context, Check, Outcome, float], None] | None = None
44
+ error: Callable[[Context, Check, BaseException], None] | None = None
45
+ finally_: Callable[[Decision], None] | None = None
46
+
47
+
48
+ _sequence = 0
49
+
50
+
51
+ def _next_sequence() -> int:
52
+ global _sequence
53
+ _sequence += 1
54
+ return _sequence
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class Evaluation:
59
+ """Pure state machine: ask ``pending()`` which check is next, fetch ``keys()``, ``feed()`` the values, repeat."""
60
+
61
+ checks: Sequence[Check]
62
+ input: EvaluateInput
63
+ on_store_error: StoreErrorMode = "open"
64
+ hooks: Hooks = field(default_factory=Hooks)
65
+ now: datetime = field(init=False)
66
+ _index: int = 0
67
+ _trace: list[TraceEntry] = field(default_factory=list)
68
+ _shadowed: list[str] = field(default_factory=list)
69
+ _near: list[NearLimitNote] = field(default_factory=list)
70
+ _surfaces: tuple[str, ...] = ()
71
+ _deliver_at: datetime | None = None
72
+ _result: Decision | None = None
73
+
74
+ def __post_init__(self) -> None:
75
+ self.now = self.input.now or datetime.now(UTC)
76
+ self._surfaces = surfaces_for(self.input.user, self.input.candidate)
77
+
78
+ def context(self) -> Context:
79
+ return Context(self.input.user, self.input.candidate, self.now, self.input.candidate.priority, self._surfaces)
80
+
81
+ def pending(self) -> Check | None:
82
+ if self._result is not None or self._index >= len(self.checks):
83
+ return None
84
+ return self.checks[self._index]
85
+
86
+ def keys(self) -> Sequence[str]:
87
+ check = self.pending()
88
+ return check.keys(self.context()) if check else ()
89
+
90
+ def _call(self, name: str, *args: Any) -> None:
91
+ hook = getattr(self.hooks, name)
92
+ if hook is None:
93
+ return
94
+ try:
95
+ hook(*args)
96
+ except BaseException as error: # noqa: BLE001 - hooks must never break a decision
97
+ if name != "error" and self.hooks.error is not None and len(args) >= 2:
98
+ try:
99
+ self.hooks.error(args[0], args[1], error)
100
+ except BaseException: # noqa: BLE001
101
+ pass
102
+
103
+ def feed(self, values: Values | None, error: BaseException | None = None) -> None:
104
+ check = self.pending()
105
+ if check is None:
106
+ raise RuntimeError("nothing pending")
107
+ ctx = self.context()
108
+ self._index += 1
109
+ self._call("before", ctx, check)
110
+ started = time.perf_counter()
111
+ outcome: Outcome
112
+ if error is None:
113
+ try:
114
+ outcome = check.run(ctx, values or {})
115
+ except BaseException as raised: # noqa: BLE001 - a check failure is a trace entry, not a crash
116
+ error = raised
117
+ if error is not None:
118
+ message = str(error) or error.__class__.__name__
119
+ self._call("error", ctx, check, error)
120
+ ms = _elapsed(started)
121
+ if self.on_store_error == "closed":
122
+ self._trace.append(TraceEntry(check.id, "reject", ms, f"check threw ({message}); failing closed"))
123
+ self._finish(rejected_by=check.id, reason=f'check "{check.id}" failed and the gate fails closed: {message}')
124
+ return
125
+ self._trace.append(TraceEntry(check.id, "skip", ms, f"check threw ({message}); failing open"))
126
+ self._maybe_finish()
127
+ return
128
+ ms = _elapsed(started)
129
+ self._call("after", ctx, check, outcome, ms)
130
+ if check.non_rejecting and outcome.kind in ("reject", "defer"):
131
+ self._trace.append(TraceEntry(check.id, "skip", ms, f"non-rejecting check returned {outcome.kind} ({outcome.reason}); ignored"))
132
+ self._maybe_finish()
133
+ return
134
+ stops = outcome.kind in ("reject", "defer")
135
+ self._trace.append(TraceEntry(check.id, outcome.kind, ms, outcome.reason or None, shadow=bool(stops and check.shadow)))
136
+ if outcome.kind == "pass" and outcome.near_limit is not None:
137
+ self._near.append(NearLimitNote(check.id, outcome.near_limit.used, outcome.near_limit.limit))
138
+ if stops and check.shadow:
139
+ self._shadowed.append(check.id)
140
+ self._maybe_finish()
141
+ return
142
+ if outcome.kind == "reject":
143
+ self._finish(rejected_by=check.id, reason=outcome.reason)
144
+ return
145
+ if outcome.kind == "defer":
146
+ self._finish(deferred_by=check.id, retry_at=outcome.retry_at, reason=outcome.reason)
147
+ return
148
+ if outcome.kind == "adjust":
149
+ if outcome.deliver_at is not None:
150
+ self._deliver_at = outcome.deliver_at
151
+ if outcome.surfaces is not None:
152
+ self._surfaces = tuple(outcome.surfaces)
153
+ self._maybe_finish()
154
+
155
+ def _maybe_finish(self) -> None:
156
+ if self._index >= len(self.checks):
157
+ self._finish(allowed=True)
158
+
159
+ def _finish(
160
+ self,
161
+ allowed: bool = False,
162
+ rejected_by: str | None = None,
163
+ deferred_by: str | None = None,
164
+ retry_at: datetime | None = None,
165
+ reason: str | None = None,
166
+ ) -> None:
167
+ user, candidate = self.input.user, self.input.candidate
168
+ self._result = Decision(
169
+ id=f"{user.id}:{candidate.id}:{iso_z(self.now)}#{_next_sequence()}",
170
+ allowed=allowed,
171
+ user_id=user.id,
172
+ candidate_id=candidate.id,
173
+ surfaces=self._surfaces if allowed else (),
174
+ trace=tuple(self._trace),
175
+ evaluated_at=self.now,
176
+ shadowed=tuple(self._shadowed),
177
+ near_limit=tuple(self._near),
178
+ deliver_at=self._deliver_at if allowed else None,
179
+ rejected_by=rejected_by,
180
+ deferred_by=deferred_by,
181
+ retry_at=retry_at,
182
+ reason=reason,
183
+ )
184
+ self._call("finally_", self._result)
185
+
186
+ def decision(self) -> Decision:
187
+ if self._result is None:
188
+ raise RuntimeError("evaluation has not finished")
189
+ return self._result
190
+
191
+
192
+ def decide(checks: Sequence[Check], input: EvaluateInput, values: Mapping[str, Values] | None = None, hooks: Hooks | None = None, on_store_error: StoreErrorMode = "open") -> Decision:
193
+ """Pure decision from precomputed values, keyed by check id. No store, no clock beyond ``input.now``."""
194
+ ev = Evaluation(checks, input, on_store_error, hooks or Hooks())
195
+ while (check := ev.pending()) is not None:
196
+ ev.feed((values or {}).get(check.id, {}))
197
+ return ev.decision()
198
+
199
+
200
+ def _elapsed(started: float) -> float:
201
+ return round((time.perf_counter() - started) * 1000, 3)
202
+
203
+
204
+ def _consumers(checks: Iterable[Check]) -> list[Check]:
205
+ return [c for c in checks if isinstance(c, (Budget, OnlyWhen)) and hasattr(c, "consume_plan")]
206
+
207
+
208
+ def _plan(check: Check, ctx: Context) -> ConsumePlan | None:
209
+ plan = getattr(check, "consume_plan")(ctx)
210
+ return plan if isinstance(plan, ConsumePlan) else None
211
+
212
+
213
+ class _Prefixed:
214
+ def __init__(self, prefix: str) -> None:
215
+ self.prefix = prefix
216
+
217
+ def key(self, key: str) -> str:
218
+ return self.prefix + key
219
+
220
+
221
+ class Gate:
222
+ """Synchronous gate over a ``Store``."""
223
+
224
+ def __init__(
225
+ self,
226
+ checks: Sequence[Check],
227
+ store: Store | None = None,
228
+ on_store_error: StoreErrorMode = "open",
229
+ key_prefix: str = "pg:",
230
+ on_decision: Callable[[Decision], None] | None = None,
231
+ hooks: Hooks | None = None,
232
+ ) -> None:
233
+ self.checks: tuple[Check, ...] = tuple(checks)
234
+ self.store: Store = store if store is not None else MemoryStore()
235
+ self.on_store_error: StoreErrorMode = on_store_error
236
+ self._p = _Prefixed(key_prefix)
237
+ self.on_decision = on_decision
238
+ self.hooks = hooks or Hooks()
239
+ self._consumers = _consumers(self.checks)
240
+
241
+ @classmethod
242
+ def from_policy(cls, policy: Mapping[str, Any], store: Store | None = None, on_decision: Callable[[Decision], None] | None = None, hooks: Hooks | None = None) -> "Gate":
243
+ from .policy import compile_policy
244
+
245
+ compiled = compile_policy(policy)
246
+ return cls(compiled.checks, store, compiled.on_store_error, compiled.key_prefix, on_decision, hooks)
247
+
248
+ def evaluate(self, input: EvaluateInput) -> Decision:
249
+ ev = Evaluation(self.checks, input, self.on_store_error, self.hooks)
250
+ while ev.pending() is not None:
251
+ try:
252
+ values = {k: self.store.get(self._p.key(k)) for k in ev.keys()}
253
+ except Exception as error: # noqa: BLE001 - a store failure is a trace entry
254
+ ev.feed(None, error)
255
+ continue
256
+ ev.feed(values)
257
+ decision = ev.decision()
258
+ if self.on_decision:
259
+ self.on_decision(decision)
260
+ return decision
261
+
262
+ def commit(self, decision: Decision, input: EvaluateInput) -> bool:
263
+ if not decision.allowed:
264
+ return False
265
+ if not self._consumers:
266
+ return True
267
+ now = input.now or decision.evaluated_at
268
+ marker = self._p.key(f"commit:{decision.id}")
269
+ try:
270
+ seen = self.store.get(marker)
271
+ if seen is not None:
272
+ return seen == "1"
273
+ ok = True
274
+ ctx = Context(input.user, input.candidate, now, input.candidate.priority, decision.surfaces)
275
+ for check in self._consumers:
276
+ plan = _plan(check, ctx)
277
+ if plan is None:
278
+ continue
279
+ if self.store.incr(self._p.key(plan.key), plan.ttl_seconds) > plan.limit:
280
+ ok = False
281
+ break
282
+ self.store.set(marker, "1" if ok else "0", COMMIT_TTL)
283
+ return ok
284
+ except Exception: # noqa: BLE001
285
+ return self.on_store_error == "open"
286
+
287
+ def record(self, user: UserState, candidate: Candidate, event: OutcomeEvent, at: datetime | None = None) -> None:
288
+ if event != "dismissed":
289
+ return
290
+ at = at or datetime.now(UTC)
291
+ key = self._p.key(dismissal_key(user.id, candidate.type))
292
+ raw = self.store.get(key)
293
+ stamps: list[int] = json.loads(raw) if raw else []
294
+ keep_from = epoch_ms(at) - 90 * DAY_SECONDS * 1000
295
+ stamps = [t for t in stamps if t >= keep_from] + [epoch_ms(at)]
296
+ self.store.set(key, json.dumps(stamps, separators=(",", ":")), 90 * DAY_SECONDS)
297
+
298
+ def inspect(self, user: UserState, now: datetime | None = None) -> dict[str, Any]:
299
+ now = now or datetime.now(UTC)
300
+ used = int(self.store.get(self._p.key(budget_key(user.id, now, user.timezone))) or 0)
301
+ dismissals: dict[str, int] = {}
302
+ for type_ in user.muted_types:
303
+ raw = self.store.get(self._p.key(dismissal_key(user.id, type_)))
304
+ dismissals[type_] = len(json.loads(raw)) if raw else 0
305
+ return {"budgetUsed": used, "dismissals": dismissals}
306
+
307
+
308
+ class AsyncGate:
309
+ """The same gate over an ``AsyncStore``; the decision logic is the shared ``Evaluation``."""
310
+
311
+ def __init__(
312
+ self,
313
+ checks: Sequence[Check],
314
+ store: AsyncStore,
315
+ on_store_error: StoreErrorMode = "open",
316
+ key_prefix: str = "pg:",
317
+ on_decision: Callable[[Decision], None] | None = None,
318
+ hooks: Hooks | None = None,
319
+ ) -> None:
320
+ self.checks: tuple[Check, ...] = tuple(checks)
321
+ self.store = store
322
+ self.on_store_error: StoreErrorMode = on_store_error
323
+ self._p = _Prefixed(key_prefix)
324
+ self.on_decision = on_decision
325
+ self.hooks = hooks or Hooks()
326
+ self._consumers = _consumers(self.checks)
327
+
328
+ @classmethod
329
+ def from_policy(cls, policy: Mapping[str, Any], store: AsyncStore, on_decision: Callable[[Decision], None] | None = None, hooks: Hooks | None = None) -> "AsyncGate":
330
+ from .policy import compile_policy
331
+
332
+ compiled = compile_policy(policy)
333
+ return cls(compiled.checks, store, compiled.on_store_error, compiled.key_prefix, on_decision, hooks)
334
+
335
+ async def evaluate(self, input: EvaluateInput) -> Decision:
336
+ ev = Evaluation(self.checks, input, self.on_store_error, self.hooks)
337
+ while ev.pending() is not None:
338
+ try:
339
+ values = {k: await self.store.get(self._p.key(k)) for k in ev.keys()}
340
+ except Exception as error: # noqa: BLE001
341
+ ev.feed(None, error)
342
+ continue
343
+ ev.feed(values)
344
+ decision = ev.decision()
345
+ if self.on_decision:
346
+ self.on_decision(decision)
347
+ return decision
348
+
349
+ async def commit(self, decision: Decision, input: EvaluateInput) -> bool:
350
+ if not decision.allowed:
351
+ return False
352
+ if not self._consumers:
353
+ return True
354
+ now = input.now or decision.evaluated_at
355
+ marker = self._p.key(f"commit:{decision.id}")
356
+ try:
357
+ seen = await self.store.get(marker)
358
+ if seen is not None:
359
+ return seen == "1"
360
+ ok = True
361
+ ctx = Context(input.user, input.candidate, now, input.candidate.priority, decision.surfaces)
362
+ for check in self._consumers:
363
+ plan = _plan(check, ctx)
364
+ if plan is None:
365
+ continue
366
+ if await self.store.incr(self._p.key(plan.key), plan.ttl_seconds) > plan.limit:
367
+ ok = False
368
+ break
369
+ await self.store.set(marker, "1" if ok else "0", COMMIT_TTL)
370
+ return ok
371
+ except Exception: # noqa: BLE001
372
+ return self.on_store_error == "open"
373
+
374
+ async def record(self, user: UserState, candidate: Candidate, event: OutcomeEvent, at: datetime | None = None) -> None:
375
+ if event != "dismissed":
376
+ return
377
+ at = at or datetime.now(UTC)
378
+ key = self._p.key(dismissal_key(user.id, candidate.type))
379
+ raw = await self.store.get(key)
380
+ stamps: list[int] = json.loads(raw) if raw else []
381
+ keep_from = epoch_ms(at) - 90 * DAY_SECONDS * 1000
382
+ stamps = [t for t in stamps if t >= keep_from] + [epoch_ms(at)]
383
+ await self.store.set(key, json.dumps(stamps, separators=(",", ":")), 90 * DAY_SECONDS)
384
+
385
+ async def inspect(self, user: UserState, now: datetime | None = None) -> dict[str, Any]:
386
+ now = now or datetime.now(UTC)
387
+ used = int(await self.store.get(self._p.key(budget_key(user.id, now, user.timezone))) or 0)
388
+ dismissals: dict[str, int] = {}
389
+ for type_ in user.muted_types:
390
+ raw = await self.store.get(self._p.key(dismissal_key(user.id, type_)))
391
+ dismissals[type_] = len(json.loads(raw)) if raw else 0
392
+ return {"budgetUsed": used, "dismissals": dismissals}
393
+
394
+
395
+ __all__ = ["AsyncGate", "Evaluation", "Gate", "Hooks", "decide"]
@@ -0,0 +1,94 @@
1
+ """JSON policies (spec/schema/policy.schema.json) compiled into checks."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable, Mapping
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from . import checks as c
9
+ from .checks import Check
10
+ from .presets import presets
11
+ from .types import StoreErrorMode
12
+
13
+ Options = Mapping[str, Any]
14
+ Factory = Callable[[Options], Check]
15
+
16
+ SUPPORTED_MAJOR = 1
17
+
18
+
19
+ def _budget(cls: type[c.Budget]) -> Factory:
20
+ return lambda o: cls(limit=o.get("limit"), bypass_priority=o.get("bypassPriority"), near_limit=o.get("nearLimit", 0.8))
21
+
22
+
23
+ KNOWN_CHECKS: dict[str, Factory] = {
24
+ "killSwitch": lambda o: c.KillSwitch(bool(o.get("on", False))),
25
+ "consent": lambda o: c.Consent(),
26
+ "enabled": lambda o: c.Enabled(),
27
+ "mode": lambda o: c.Mode(o.get("allow", ["normal"])),
28
+ "snooze": lambda o: c.Snooze(defer=bool(o.get("defer", False))),
29
+ "mute": lambda o: c.Mute(),
30
+ "intensity": lambda o: c.Intensity(o.get("floors")),
31
+ "quietHours": lambda o: c.QuietHours(o.get("priorityFloor", "critical")),
32
+ "trustRamp": lambda o: c.TrustRamp(o.get("days", 7), o.get("minPriority", "high")),
33
+ "dismissalCooldown": lambda o: c.DismissalCooldown(o.get("dismissals", 3), o.get("withinDays", 30), o.get("silenceDays", 7)),
34
+ "adaptiveTiming": lambda o: c.AdaptiveTiming(),
35
+ "dailyBudget": _budget(c.DailyBudget),
36
+ "weeklyBudget": _budget(c.WeeklyBudget),
37
+ "monthlyBudget": _budget(c.MonthlyBudget),
38
+ "utilityFloor": lambda o: c.UtilityFloor(o.get("costFalseAlarm", 1), o.get("costMissedHelp", 1)),
39
+ "boundedDeferral": lambda o: c.BoundedDeferral(o.get("lambda", 1 / 43), o.get("interruptCost", 1), o.get("staleness", 0.0001), o.get("boundSeconds", 240)),
40
+ "allowedWindow": lambda o: c.AllowedWindow(o.get("start", "08:00"), o.get("end", "21:00"), o.get("timezone", "user"), o.get("priorityFloor"), o.get("id", "allowedWindow")),
41
+ "requiresConsent": lambda o: c.RequiresConsent(o.get("name", "consent"), o.get("when"), o.get("id")),
42
+ "rateLimit": lambda o: c.RateLimit(o.get("limit", 1), o.get("perSeconds", 1), o.get("keyBy", "user"), o.get("id")),
43
+ "recentInteraction": lambda o: c.RecentInteraction(o.get("withinHours", 48)),
44
+ "windowBudget": lambda o: c.WindowBudget(o.get("limit", 1), o.get("withinHours", 48)),
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class CompiledPolicy:
50
+ checks: tuple[Check, ...]
51
+ on_store_error: StoreErrorMode
52
+ key_prefix: str
53
+
54
+
55
+ def compile_policy(policy: Mapping[str, Any]) -> CompiledPolicy:
56
+ """Raises ``ValueError`` on an unknown id or preset (naming the known ones) or an unsupported spec major."""
57
+ version = str(policy.get("specVersion", ""))
58
+ major = version.split(".")[0]
59
+ if not major.isdigit() or int(major) != SUPPORTED_MAJOR:
60
+ raise ValueError(f"policy specVersion {version} is not supported; this package implements spec {SUPPORTED_MAJOR}.x")
61
+ entries = policy.get("checks")
62
+ if not isinstance(entries, list) or not entries:
63
+ raise ValueError("policy.checks must be a non-empty array")
64
+ compiled: list[Check] = []
65
+ for entry in entries:
66
+ if not isinstance(entry, Mapping):
67
+ raise ValueError("each policy entry needs an id or a preset")
68
+ options = {k: v for k, v in entry.items() if k not in ("id", "preset", "shadow")}
69
+ preset_name = entry.get("preset")
70
+ check_id = entry.get("id")
71
+ built: list[Check]
72
+ if isinstance(preset_name, str):
73
+ preset = presets.get(preset_name)
74
+ if preset is None:
75
+ raise ValueError(f'unknown preset "{preset_name}"; known presets: {", ".join(presets)}')
76
+ built = preset(options)
77
+ elif isinstance(check_id, str):
78
+ factory = KNOWN_CHECKS.get(check_id)
79
+ if factory is None:
80
+ raise ValueError(f'unknown check "{check_id}"; known checks: {", ".join(KNOWN_CHECKS)}')
81
+ built = [factory(options)]
82
+ else:
83
+ raise ValueError("each policy entry needs an id or a preset")
84
+ if entry.get("shadow"):
85
+ for check in built:
86
+ check.shadow = True
87
+ compiled.extend(built)
88
+ on_store_error: StoreErrorMode = "closed" if policy.get("onStoreError") == "closed" else "open"
89
+ return CompiledPolicy(tuple(compiled), on_store_error, str(policy.get("keyPrefix", "pg:")))
90
+
91
+
92
+ load_policy = compile_policy
93
+
94
+ __all__ = ["KNOWN_CHECKS", "CompiledPolicy", "compile_policy", "load_policy"]