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.
@@ -9,3 +9,6 @@ __pycache__/
9
9
  *.egg-info/
10
10
  .pytest_cache/
11
11
  .mypy_cache/
12
+
13
+ # uv writes this when the Python package is run locally; the library pins nothing.
14
+ python/uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: proactive-gate
3
- Version: 0.2.1
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
- git clone https://github.com/Bubblegunn/proactive-gate
47
- pip install ./proactive-gate/python
44
+ pip install proactive-gate
48
45
  ```
49
46
 
50
- or, without cloning, `pip install "proactive-gate @ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`.
51
- Python 3.11 or newer, no runtime dependencies. Add `[redis]` (`pip install "./proactive-gate/python[redis]"`)
52
- for the Redis store. The PyPI name `proactive-gate` is reserved for this package; the
53
- release workflow publishes there on the next tag.
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
- git clone https://github.com/Bubblegunn/proactive-gate
16
- pip install ./proactive-gate/python
13
+ pip install proactive-gate
17
14
  ```
18
15
 
19
- or, without cloning, `pip install "proactive-gate @ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`.
20
- Python 3.11 or newer, no runtime dependencies. Add `[redis]` (`pip install "./proactive-gate/python[redis]"`)
21
- for the Redis store. The PyPI name `proactive-gate` is reserved for this package; the
22
- release workflow publishes there on the next tag.
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.1"
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, _ = local_clock(ctx.now, ctx.user.timezone)
166
- if not in_window(minutes, parse_hhmm(qh.start), parse_hhmm(qh.end)):
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
- return reject(f"quiet hours {qh.start} to {qh.end} {ctx.user.timezone}; priority {ctx.priority} is below the floor ({self.floor})")
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
- return [c for c in checks if isinstance(c, (Budget, OnlyWhen)) and hasattr(c, "consume_plan")]
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 QuietHours:
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: QuietHours | None = None
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=QuietHours(str(qh["start"]), str(qh["end"])) if qh else None,
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