proactive-gate 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/.gitignore +3 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/PKG-INFO +1 -1
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/pyproject.toml +1 -1
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/src/proactive_gate/checks.py +54 -2
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/src/proactive_gate/gate.py +3 -1
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/src/proactive_gate/policy.py +1 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/src/proactive_gate/types.py +2 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/README.md +0 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/src/proactive_gate/__init__.py +0 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/src/proactive_gate/clock.py +0 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/src/proactive_gate/conformance.py +0 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/src/proactive_gate/presets.py +0 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/src/proactive_gate/py.typed +0 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/src/proactive_gate/stores.py +0 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/tests/conftest.py +0 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/tests/test_clock.py +0 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/tests/test_conformance.py +0 -0
- {proactive_gate-0.2.2 → proactive_gate-0.2.4}/tests/test_stores.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: proactive-gate
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Decide whether a proactive assistant may speak now: ordered checks, budgets, quiet hours, JSON policies, shared conformance fixtures with the TypeScript package.
|
|
5
5
|
Project-URL: Homepage, https://bubblegunn.github.io/proactive-gate/
|
|
6
6
|
Project-URL: Repository, https://github.com/Bubblegunn/proactive-gate
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proactive-gate"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "Decide whether a proactive assistant may speak now: ordered checks, budgets, quiet hours, JSON policies, shared conformance fixtures with the TypeScript package."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -526,6 +526,58 @@ class OnlyWhen(BaseCheck):
|
|
|
526
526
|
return None
|
|
527
527
|
|
|
528
528
|
|
|
529
|
+
def dedupe_key_for(user_id: str, key: str) -> str:
|
|
530
|
+
return f"dedupe:{user_id}:{key}"
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _human_window(seconds: int) -> str:
|
|
534
|
+
if seconds % DAY_SECONDS == 0:
|
|
535
|
+
return f"{seconds // DAY_SECONDS}d"
|
|
536
|
+
if seconds % 3600 == 0:
|
|
537
|
+
return f"{seconds // 3600}h"
|
|
538
|
+
if seconds % 60 == 0:
|
|
539
|
+
return f"{seconds // 60}m"
|
|
540
|
+
return f"{seconds}s"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class Dedupe(BaseCheck):
|
|
544
|
+
"""One delivery per event per window, claimed atomically at commit time.
|
|
545
|
+
|
|
546
|
+
Transports deliver at least once, so the same event arrives twice, and two
|
|
547
|
+
workers can hold it at the same moment. Both then evaluate against a store
|
|
548
|
+
where nothing is recorded, so reading cannot separate them: the claim has to
|
|
549
|
+
be atomic and it has to happen at commit. See spec clauses 5.5 to 5.8.
|
|
550
|
+
|
|
551
|
+
The window is the caller's; the 24-hour default is the common retry horizon,
|
|
552
|
+
the same figure Stripe prunes an idempotency key after and Nylas gives as the
|
|
553
|
+
safe default for webhook deduplication.
|
|
554
|
+
"""
|
|
555
|
+
|
|
556
|
+
id = "dedupe"
|
|
557
|
+
|
|
558
|
+
def __init__(self, window_seconds: int = DAY_SECONDS) -> None:
|
|
559
|
+
self.window_seconds = window_seconds
|
|
560
|
+
self.label = _human_window(window_seconds)
|
|
561
|
+
|
|
562
|
+
def _key(self, ctx: Context) -> str | None:
|
|
563
|
+
key = getattr(ctx.candidate, "dedupe_key", None)
|
|
564
|
+
return dedupe_key_for(ctx.user.id, key) if key else None
|
|
565
|
+
|
|
566
|
+
def keys(self, ctx: Context) -> Sequence[str]:
|
|
567
|
+
key = self._key(ctx)
|
|
568
|
+
return (key,) if key else ()
|
|
569
|
+
|
|
570
|
+
def run(self, ctx: Context, values: Values) -> Outcome:
|
|
571
|
+
key = self._key(ctx)
|
|
572
|
+
if key is None:
|
|
573
|
+
return skip("no dedupeKey on the candidate; deduplication cannot be evaluated")
|
|
574
|
+
return PASS if values.get(key) is None else reject(f"already delivered within the last {self.label}")
|
|
575
|
+
|
|
576
|
+
def consume_plan(self, ctx: Context) -> ConsumePlan | None:
|
|
577
|
+
key = self._key(ctx)
|
|
578
|
+
return None if key is None else ConsumePlan(key, self.window_seconds, 1)
|
|
579
|
+
|
|
580
|
+
|
|
529
581
|
def default_checks(
|
|
530
582
|
kill_switch: Callable[[], bool] | bool = False,
|
|
531
583
|
modes: Sequence[str] = ("normal",),
|
|
@@ -555,8 +607,8 @@ def default_checks(
|
|
|
555
607
|
|
|
556
608
|
__all__ = [
|
|
557
609
|
"AdaptiveTiming", "AllowedWindow", "BaseCheck", "BoundedDeferral", "Budget", "Check", "Consent", "Consumer",
|
|
558
|
-
"DailyBudget", "DismissalCooldown", "Enabled", "Intensity", "KillSwitch", "Mode", "MonthlyBudget", "Mute",
|
|
610
|
+
"DailyBudget", "Dedupe", "DismissalCooldown", "Enabled", "Intensity", "KillSwitch", "Mode", "MonthlyBudget", "Mute",
|
|
559
611
|
"OnlyWhen", "QuietHours", "RateLimit", "RecentInteraction", "RequiresConsent", "Snooze", "TrustRamp",
|
|
560
612
|
"UtilityFloor", "WeeklyBudget", "WindowBudget", "budget_key", "default_checks", "dismissal_key",
|
|
561
|
-
"monthly_budget_key", "weekly_budget_key",
|
|
613
|
+
"dedupe_key_for", "monthly_budget_key", "weekly_budget_key",
|
|
562
614
|
]
|
|
@@ -202,7 +202,9 @@ def _elapsed(started: float) -> float:
|
|
|
202
202
|
|
|
203
203
|
|
|
204
204
|
def _consumers(checks: Iterable[Check]) -> list[Check]:
|
|
205
|
-
|
|
205
|
+
"""Any check that offers a commit-time plan. Duck-typed, as the TypeScript side is:
|
|
206
|
+
a new consumer must not have to be added to a class list to be honoured."""
|
|
207
|
+
return [c for c in checks if hasattr(c, "consume_plan")]
|
|
206
208
|
|
|
207
209
|
|
|
208
210
|
def _plan(check: Check, ctx: Context) -> ConsumePlan | None:
|
|
@@ -32,6 +32,7 @@ KNOWN_CHECKS: dict[str, Factory] = {
|
|
|
32
32
|
"trustRamp": lambda o: c.TrustRamp(o.get("days", 7), o.get("minPriority", "high")),
|
|
33
33
|
"dismissalCooldown": lambda o: c.DismissalCooldown(o.get("dismissals", 3), o.get("withinDays", 30), o.get("silenceDays", 7)),
|
|
34
34
|
"adaptiveTiming": lambda o: c.AdaptiveTiming(),
|
|
35
|
+
"dedupe": lambda o: c.Dedupe(int(o.get("windowSeconds", 86400))),
|
|
35
36
|
"dailyBudget": _budget(c.DailyBudget),
|
|
36
37
|
"weeklyBudget": _budget(c.WeeklyBudget),
|
|
37
38
|
"monthlyBudget": _budget(c.MonthlyBudget),
|
|
@@ -135,6 +135,7 @@ class Candidate:
|
|
|
135
135
|
busy: bool | None = None
|
|
136
136
|
p_accept: float | None = None
|
|
137
137
|
p_need: float | None = None
|
|
138
|
+
dedupe_key: str | None = None
|
|
138
139
|
payload: Any = None
|
|
139
140
|
|
|
140
141
|
@staticmethod
|
|
@@ -148,6 +149,7 @@ class Candidate:
|
|
|
148
149
|
busy=data.get("busy"),
|
|
149
150
|
p_accept=data.get("pAccept"),
|
|
150
151
|
p_need=data.get("pNeed"),
|
|
152
|
+
dedupe_key=data.get("dedupeKey"),
|
|
151
153
|
payload=data.get("payload"),
|
|
152
154
|
)
|
|
153
155
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|