proactive-gate 0.2.1__tar.gz → 0.2.3__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.1 → proactive_gate-0.2.3}/.gitignore +3 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/PKG-INFO +11 -9
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/README.md +10 -8
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/pyproject.toml +1 -1
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/src/proactive_gate/checks.py +98 -6
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/src/proactive_gate/clock.py +17 -1
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/src/proactive_gate/gate.py +3 -1
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/src/proactive_gate/policy.py +1 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/src/proactive_gate/types.py +40 -3
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/src/proactive_gate/__init__.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/src/proactive_gate/conformance.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/src/proactive_gate/presets.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/src/proactive_gate/py.typed +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/src/proactive_gate/stores.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/tests/conftest.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/tests/test_clock.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/tests/test_conformance.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.3}/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.3
|
|
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
|
|
@@ -40,17 +40,19 @@ This is the Python sibling of the TypeScript package. Both implement the same be
|
|
|
40
40
|
contract in [`spec/`](https://github.com/Bubblegunn/proactive-gate/tree/main/spec) and run
|
|
41
41
|
the same fixtures, so a policy written for one behaves the same in the other.
|
|
42
42
|
|
|
43
|
-
Until the first PyPI release, install from the repository:
|
|
44
|
-
|
|
45
43
|
```
|
|
46
|
-
|
|
47
|
-
pip install ./proactive-gate/python
|
|
44
|
+
pip install proactive-gate
|
|
48
45
|
```
|
|
49
46
|
|
|
50
|
-
or,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
Python 3.11 or newer, no runtime dependencies. Add `[redis]` (`pip install "proactive-gate[redis]"`)
|
|
48
|
+
for the Redis store.
|
|
49
|
+
|
|
50
|
+
To run an unreleased state, install from the repository instead:
|
|
51
|
+
`pip install "proactive-gate @ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`.
|
|
52
|
+
|
|
53
|
+
The published 0.2.1 was uploaded from a local build with a token, so unlike the npm package it
|
|
54
|
+
carries no build provenance. Trusted publishing is not configured yet; when it is, releases will
|
|
55
|
+
come from the workflow.
|
|
54
56
|
|
|
55
57
|
## Use
|
|
56
58
|
|
|
@@ -9,17 +9,19 @@ This is the Python sibling of the TypeScript package. Both implement the same be
|
|
|
9
9
|
contract in [`spec/`](https://github.com/Bubblegunn/proactive-gate/tree/main/spec) and run
|
|
10
10
|
the same fixtures, so a policy written for one behaves the same in the other.
|
|
11
11
|
|
|
12
|
-
Until the first PyPI release, install from the repository:
|
|
13
|
-
|
|
14
12
|
```
|
|
15
|
-
|
|
16
|
-
pip install ./proactive-gate/python
|
|
13
|
+
pip install proactive-gate
|
|
17
14
|
```
|
|
18
15
|
|
|
19
|
-
or,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
Python 3.11 or newer, no runtime dependencies. Add `[redis]` (`pip install "proactive-gate[redis]"`)
|
|
17
|
+
for the Redis store.
|
|
18
|
+
|
|
19
|
+
To run an unreleased state, install from the repository instead:
|
|
20
|
+
`pip install "proactive-gate @ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`.
|
|
21
|
+
|
|
22
|
+
The published 0.2.1 was uploaded from a local build with a token, so unlike the npm package it
|
|
23
|
+
carries no build provenance. Trusted publishing is not configured yet; when it is, releases will
|
|
24
|
+
come from the workflow.
|
|
23
25
|
|
|
24
26
|
## Use
|
|
25
27
|
|
|
@@ -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.3"
|
|
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"
|
|
@@ -10,7 +10,7 @@ from collections.abc import Callable, Mapping, Sequence
|
|
|
10
10
|
from datetime import datetime, timedelta
|
|
11
11
|
from typing import Protocol, runtime_checkable
|
|
12
12
|
|
|
13
|
-
from .clock import DAY_SECONDS, in_window, iso_week_key, local_clock, local_day, parse_hhmm
|
|
13
|
+
from .clock import DAY_SECONDS, day_before, in_window, iso_week_key, local_clock, local_day, parse_hhmm, weekday_of
|
|
14
14
|
from .types import (
|
|
15
15
|
PASS,
|
|
16
16
|
ConsumePlan,
|
|
@@ -18,6 +18,8 @@ from .types import (
|
|
|
18
18
|
NearLimit,
|
|
19
19
|
Outcome,
|
|
20
20
|
Priority,
|
|
21
|
+
QuietSchedule,
|
|
22
|
+
QuietWindow,
|
|
21
23
|
at_least,
|
|
22
24
|
defer,
|
|
23
25
|
epoch_ms,
|
|
@@ -150,6 +152,39 @@ class Intensity(BaseCheck):
|
|
|
150
152
|
return reject(f'priority {ctx.priority} is below the "{level}" intensity floor ({floor})')
|
|
151
153
|
|
|
152
154
|
|
|
155
|
+
def window_for(quiet: QuietWindow | QuietSchedule, day: str) -> QuietWindow | None:
|
|
156
|
+
"""The window in force on one local date: a date beats a weekday beats the default."""
|
|
157
|
+
if isinstance(quiet, QuietWindow):
|
|
158
|
+
return quiet
|
|
159
|
+
if day in quiet.dates:
|
|
160
|
+
return quiet.dates[day]
|
|
161
|
+
weekday = weekday_of(day)
|
|
162
|
+
if weekday in quiet.days:
|
|
163
|
+
return quiet.days[weekday]
|
|
164
|
+
return quiet.default
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def quiet_at(quiet: QuietWindow | QuietSchedule, day: str, minutes: int) -> tuple[QuietWindow, str] | None:
|
|
168
|
+
"""The window silencing ``minutes`` on ``day``, and the day it opened on.
|
|
169
|
+
|
|
170
|
+
A window that crosses midnight belongs to the day it opens on, so a time can be
|
|
171
|
+
quiet because of yesterday. With one window every day this reduces exactly to
|
|
172
|
+
``in_window``, which is why the single-window form behaves as it did.
|
|
173
|
+
"""
|
|
174
|
+
today = window_for(quiet, day)
|
|
175
|
+
if today is not None:
|
|
176
|
+
start, end = parse_hhmm(today.start), parse_hhmm(today.end)
|
|
177
|
+
if start != end and (start <= minutes < end if start < end else minutes >= start):
|
|
178
|
+
return today, day
|
|
179
|
+
previous = day_before(day)
|
|
180
|
+
yesterday = window_for(quiet, previous)
|
|
181
|
+
if yesterday is not None:
|
|
182
|
+
start, end = parse_hhmm(yesterday.start), parse_hhmm(yesterday.end)
|
|
183
|
+
if start > end and minutes < end:
|
|
184
|
+
return yesterday, previous
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
153
188
|
class QuietHours(BaseCheck):
|
|
154
189
|
id = "quietHours"
|
|
155
190
|
|
|
@@ -162,12 +197,17 @@ class QuietHours(BaseCheck):
|
|
|
162
197
|
return PASS
|
|
163
198
|
if not ctx.user.timezone:
|
|
164
199
|
return skip("quiet hours set but no timezone on the user; cannot evaluate")
|
|
165
|
-
minutes,
|
|
166
|
-
|
|
200
|
+
minutes, day = local_clock(ctx.now, ctx.user.timezone)
|
|
201
|
+
hit = quiet_at(qh, day, minutes)
|
|
202
|
+
if hit is None:
|
|
167
203
|
return PASS
|
|
168
204
|
if at_least(ctx.priority, self.floor):
|
|
169
205
|
return PASS
|
|
170
|
-
|
|
206
|
+
window, from_day = hit
|
|
207
|
+
# Name the day the window opened on when it was not today: the reason is
|
|
208
|
+
# yesterday's setting, and a reader checking today's would not find it.
|
|
209
|
+
whose = "" if from_day == day else f" ({weekday_of(from_day)} {from_day})"
|
|
210
|
+
return reject(f"quiet hours {window.start} to {window.end}{whose} {ctx.user.timezone}; priority {ctx.priority} is below the floor ({self.floor})")
|
|
171
211
|
|
|
172
212
|
|
|
173
213
|
class TrustRamp(BaseCheck):
|
|
@@ -486,6 +526,58 @@ class OnlyWhen(BaseCheck):
|
|
|
486
526
|
return None
|
|
487
527
|
|
|
488
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
|
+
|
|
489
581
|
def default_checks(
|
|
490
582
|
kill_switch: Callable[[], bool] | bool = False,
|
|
491
583
|
modes: Sequence[str] = ("normal",),
|
|
@@ -515,8 +607,8 @@ def default_checks(
|
|
|
515
607
|
|
|
516
608
|
__all__ = [
|
|
517
609
|
"AdaptiveTiming", "AllowedWindow", "BaseCheck", "BoundedDeferral", "Budget", "Check", "Consent", "Consumer",
|
|
518
|
-
"DailyBudget", "DismissalCooldown", "Enabled", "Intensity", "KillSwitch", "Mode", "MonthlyBudget", "Mute",
|
|
610
|
+
"DailyBudget", "Dedupe", "DismissalCooldown", "Enabled", "Intensity", "KillSwitch", "Mode", "MonthlyBudget", "Mute",
|
|
519
611
|
"OnlyWhen", "QuietHours", "RateLimit", "RecentInteraction", "RequiresConsent", "Snooze", "TrustRamp",
|
|
520
612
|
"UtilityFloor", "WeeklyBudget", "WindowBudget", "budget_key", "default_checks", "dismissal_key",
|
|
521
|
-
"monthly_budget_key", "weekly_budget_key",
|
|
613
|
+
"dedupe_key_for", "monthly_budget_key", "weekly_budget_key",
|
|
522
614
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Local-time arithmetic with the standard library only."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from datetime import datetime
|
|
4
|
+
from datetime import date, datetime, timedelta
|
|
5
5
|
from zoneinfo import ZoneInfo
|
|
6
6
|
|
|
7
7
|
DAY_SECONDS = 24 * 60 * 60
|
|
@@ -38,3 +38,19 @@ def local_day(now: datetime, tz: str | None) -> str:
|
|
|
38
38
|
def iso_week_key(day: str) -> str:
|
|
39
39
|
year, week, _ = datetime.strptime(day, "%Y-%m-%d").isocalendar()
|
|
40
40
|
return f"{year}-W{week:02d}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
WEEKDAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def weekday_of(day: str) -> str:
|
|
47
|
+
"""The weekday of a local calendar date, ``"mon"`` to ``"sun"``.
|
|
48
|
+
|
|
49
|
+
Calendar arithmetic on the date ``local_clock`` already resolved, never arithmetic
|
|
50
|
+
on an instant, so a 45-minute offset or a daylight-saving change cannot reach it.
|
|
51
|
+
"""
|
|
52
|
+
return WEEKDAYS[date.fromisoformat(day).weekday()]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def day_before(day: str) -> str:
|
|
56
|
+
return (date.fromisoformat(day) - timedelta(days=1)).isoformat()
|
|
@@ -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),
|
|
@@ -45,11 +45,46 @@ def epoch_ms(value: datetime) -> int:
|
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
@dataclass(frozen=True, slots=True)
|
|
48
|
-
class
|
|
48
|
+
class QuietWindow:
|
|
49
49
|
start: str
|
|
50
50
|
end: str
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class QuietSchedule:
|
|
55
|
+
"""Quiet hours resolved per day: a date beats a weekday beats the default.
|
|
56
|
+
|
|
57
|
+
``None`` at any level means the day has no quiet hours, which is how a working
|
|
58
|
+
day is carved out of a default. There is no bundled holiday calendar; the dates
|
|
59
|
+
a caller observes are the caller's to supply.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
default: QuietWindow | None = None
|
|
63
|
+
days: Mapping[str, QuietWindow | None] = field(default_factory=dict)
|
|
64
|
+
dates: Mapping[str, QuietWindow | None] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# The single-window form every caller had before schedules existed.
|
|
68
|
+
QuietHours = QuietWindow
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _window(value: Any) -> QuietWindow | None:
|
|
72
|
+
return QuietWindow(str(value["start"]), str(value["end"])) if value else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_quiet_hours(value: Any) -> QuietWindow | QuietSchedule | None:
|
|
76
|
+
"""A window, or a schedule; the two forms are told apart by the ``start`` key."""
|
|
77
|
+
if not value:
|
|
78
|
+
return None
|
|
79
|
+
if "start" in value:
|
|
80
|
+
return _window(value)
|
|
81
|
+
return QuietSchedule(
|
|
82
|
+
default=_window(value.get("default")),
|
|
83
|
+
days={k: _window(v) for k, v in (value.get("days") or {}).items()},
|
|
84
|
+
dates={k: _window(v) for k, v in (value.get("dates") or {}).items()},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
53
88
|
@dataclass(frozen=True, slots=True)
|
|
54
89
|
class UserState:
|
|
55
90
|
id: str
|
|
@@ -60,7 +95,7 @@ class UserState:
|
|
|
60
95
|
muted_types: tuple[str, ...] = ()
|
|
61
96
|
intensity: Intensity | None = None
|
|
62
97
|
timezone: str | None = None
|
|
63
|
-
quiet_hours:
|
|
98
|
+
quiet_hours: QuietWindow | QuietSchedule | None = None
|
|
64
99
|
created_at: datetime | None = None
|
|
65
100
|
surfaces: tuple[str, ...] | None = None
|
|
66
101
|
consents: Mapping[str, bool] = field(default_factory=dict)
|
|
@@ -80,7 +115,7 @@ class UserState:
|
|
|
80
115
|
muted_types=tuple(data.get("mutedTypes") or ()),
|
|
81
116
|
intensity=data.get("intensity"),
|
|
82
117
|
timezone=data.get("timezone"),
|
|
83
|
-
quiet_hours=
|
|
118
|
+
quiet_hours=parse_quiet_hours(qh),
|
|
84
119
|
created_at=to_datetime(data.get("createdAt")),
|
|
85
120
|
surfaces=tuple(data["surfaces"]) if data.get("surfaces") is not None else None,
|
|
86
121
|
consents=dict(data.get("consents") or {}),
|
|
@@ -100,6 +135,7 @@ class Candidate:
|
|
|
100
135
|
busy: bool | None = None
|
|
101
136
|
p_accept: float | None = None
|
|
102
137
|
p_need: float | None = None
|
|
138
|
+
dedupe_key: str | None = None
|
|
103
139
|
payload: Any = None
|
|
104
140
|
|
|
105
141
|
@staticmethod
|
|
@@ -113,6 +149,7 @@ class Candidate:
|
|
|
113
149
|
busy=data.get("busy"),
|
|
114
150
|
p_accept=data.get("pAccept"),
|
|
115
151
|
p_need=data.get("pNeed"),
|
|
152
|
+
dedupe_key=data.get("dedupeKey"),
|
|
116
153
|
payload=data.get("payload"),
|
|
117
154
|
)
|
|
118
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
|