proactive-gate 0.8.2__tar.gz → 0.9.0__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.8.2 → proactive_gate-0.9.0}/.gitignore +3 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/PKG-INFO +4 -7
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/README.md +1 -4
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/pyproject.toml +4 -2
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/__init__.py +1 -1
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/checks.py +62 -2
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/clock.py +32 -1
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/conformance.py +2 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/gate.py +21 -2
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/types.py +7 -0
- proactive_gate-0.9.0/tests/test_next_allowed_at.py +175 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/explain.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/policy.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/presets.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/py.typed +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/stores.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/conftest.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_clock.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_conformance.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_explain.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_redis.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_requirements_ci.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_stores.py +0 -0
- {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_version.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: proactive-gate
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: proactive-gate is a library that decides whether an AI agent may message a user right now (quiet hours, daily limit, consent, snooze) and records the reason when it may not.
|
|
5
5
|
Project-URL: Homepage, https://bubblegunn.github.io/proactive-gate/
|
|
6
6
|
Project-URL: Documentation, https://bubblegunn.github.io/proactive-gate/start/
|
|
7
7
|
Project-URL: Playground, https://bubblegunn.github.io/proactive-gate/playground/
|
|
@@ -11,7 +11,7 @@ Project-URL: Specification, https://github.com/Bubblegunn/proactive-gate/tree/ma
|
|
|
11
11
|
Project-URL: Changelog, https://github.com/Bubblegunn/proactive-gate/blob/main/CHANGELOG.md
|
|
12
12
|
Author: Efe Genc
|
|
13
13
|
License-Expression: MIT
|
|
14
|
-
Keywords: agents,ai-agents,consent,guardrails,interruption,llm,notification-budget,notification-policy,notifications,policy,policy-engine,proactive,proactive-agents,proactive-ai,quiet-hours,rate-limit
|
|
14
|
+
Keywords: agent-notification-policy,agents,ai-agents,consent,do-not-disturb,frequency-cap,guardrails,interruption,llm,notification-budget,notification-fatigue,notification-policy,notification-throttle,notifications,policy,policy-engine,proactive,proactive-agent,proactive-agents,proactive-ai,quiet-hours,rate-limit,tcpa,timezone
|
|
15
15
|
Classifier: Development Status :: 4 - Beta
|
|
16
16
|
Classifier: Framework :: AsyncIO
|
|
17
17
|
Classifier: Intended Audience :: Developers
|
|
@@ -40,10 +40,7 @@ Description-Content-Type: text/markdown
|
|
|
40
40
|
|
|
41
41
|
# proactive-gate for Python
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
(consent, mode, snooze, mute, intensity, quiet hours, trust ramp, dismissal cooldown,
|
|
45
|
-
budgets), JSON policies, platform and regulatory presets, and a trace that says which check
|
|
46
|
-
stopped the message and why.
|
|
43
|
+
proactive-gate is a library that decides whether an AI agent may message a user right now (quiet hours, daily limit, consent, snooze) and records the reason when it may not. It has ordered checks (consent, mode, snooze, mute, intensity, quiet hours, trust ramp, dismissal cooldown, budgets), JSON policies, platform and regulatory presets, and a trace that says which check stopped the message and why.
|
|
47
44
|
|
|
48
45
|
This is the Python sibling of the TypeScript package. Both implement the same behaviour
|
|
49
46
|
contract in [`spec/`](https://github.com/Bubblegunn/proactive-gate/tree/main/spec) and run
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
# proactive-gate for Python
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
(consent, mode, snooze, mute, intensity, quiet hours, trust ramp, dismissal cooldown,
|
|
5
|
-
budgets), JSON policies, platform and regulatory presets, and a trace that says which check
|
|
6
|
-
stopped the message and why.
|
|
3
|
+
proactive-gate is a library that decides whether an AI agent may message a user right now (quiet hours, daily limit, consent, snooze) and records the reason when it may not. It has ordered checks (consent, mode, snooze, mute, intensity, quiet hours, trust ramp, dismissal cooldown, budgets), JSON policies, platform and regulatory presets, and a trace that says which check stopped the message and why.
|
|
7
4
|
|
|
8
5
|
This is the Python sibling of the TypeScript package. Both implement the same behaviour
|
|
9
6
|
contract in [`spec/`](https://github.com/Bubblegunn/proactive-gate/tree/main/spec) and run
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proactive-gate"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.9.0"
|
|
8
|
+
description = "proactive-gate is a library that decides whether an AI agent may message a user right now (quiet hours, daily limit, consent, snooze) and records the reason when it may not."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
license = "MIT"
|
|
@@ -14,6 +14,8 @@ keywords = [
|
|
|
14
14
|
"proactive", "proactive-ai", "proactive-agents", "ai-agents", "llm", "agents",
|
|
15
15
|
"notifications", "notification-policy", "notification-budget", "quiet-hours",
|
|
16
16
|
"consent", "rate-limit", "policy", "policy-engine", "guardrails", "interruption",
|
|
17
|
+
"notification-throttle", "frequency-cap", "notification-fatigue", "proactive-agent",
|
|
18
|
+
"do-not-disturb", "tcpa", "timezone", "agent-notification-policy",
|
|
17
19
|
]
|
|
18
20
|
classifiers = [
|
|
19
21
|
"Development Status :: 4 - Beta",
|
|
@@ -7,10 +7,10 @@ from __future__ import annotations
|
|
|
7
7
|
import json
|
|
8
8
|
import math
|
|
9
9
|
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
-
from datetime import datetime, timedelta
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
11
|
from typing import Protocol, runtime_checkable
|
|
12
12
|
|
|
13
|
-
from .clock import DAY_SECONDS, day_before, in_window, iso_week_key, local_clock, local_day, parse_hhmm, weekday_of
|
|
13
|
+
from .clock import DAY_SECONDS, day_before, in_window, iso_week_key, local_clock, local_day, next_key_change, parse_hhmm, weekday_of
|
|
14
14
|
from .types import (
|
|
15
15
|
PASS,
|
|
16
16
|
ConsumePlan,
|
|
@@ -128,6 +128,9 @@ class Snooze(BaseCheck):
|
|
|
128
128
|
reason = f"snoozed until {iso_z(until)}"
|
|
129
129
|
return defer(reason, until) if self.defer else reject(reason)
|
|
130
130
|
|
|
131
|
+
def next_allowed_at(self, ctx: Context) -> datetime | None:
|
|
132
|
+
return ctx.user.snoozed_until
|
|
133
|
+
|
|
131
134
|
|
|
132
135
|
class Mute(BaseCheck):
|
|
133
136
|
id = "mute"
|
|
@@ -185,6 +188,37 @@ def quiet_at(quiet: QuietWindow | QuietSchedule, day: str, minutes: int) -> tupl
|
|
|
185
188
|
return None
|
|
186
189
|
|
|
187
190
|
|
|
191
|
+
_MINUTE = timedelta(minutes=1)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def quiet_ends_at(quiet: QuietWindow | QuietSchedule, tz: str, now: datetime) -> datetime | None:
|
|
195
|
+
"""The earliest minute after ``now`` at which quiet hours stop applying, or ``None``.
|
|
196
|
+
|
|
197
|
+
Each jump moves to the end of the window silencing the current local time. A daylight-saving
|
|
198
|
+
change between the two instants lands the jump an hour early (still quiet, so it jumps again)
|
|
199
|
+
or an hour late (so it steps back a minute at a time to the first open minute). Same
|
|
200
|
+
algorithm as ``quietEndsAt`` in the TypeScript package, held to the same fixtures.
|
|
201
|
+
"""
|
|
202
|
+
start = now.astimezone(UTC).replace(second=0, microsecond=0)
|
|
203
|
+
at = start
|
|
204
|
+
for _ in range(16):
|
|
205
|
+
minutes, day = local_clock(at, tz)
|
|
206
|
+
hit = quiet_at(quiet, day, minutes)
|
|
207
|
+
if hit is None:
|
|
208
|
+
for _back in range(180):
|
|
209
|
+
earlier = at - _MINUTE
|
|
210
|
+
if earlier <= start:
|
|
211
|
+
break
|
|
212
|
+
before_minutes, before_day = local_clock(earlier, tz)
|
|
213
|
+
if quiet_at(quiet, before_day, before_minutes) is not None:
|
|
214
|
+
break
|
|
215
|
+
at = earlier
|
|
216
|
+
return at if at > now else None
|
|
217
|
+
until_end = (parse_hhmm(hit[0].end) - minutes) % 1440 or 1
|
|
218
|
+
at = at + timedelta(minutes=until_end)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
188
222
|
class QuietHours(BaseCheck):
|
|
189
223
|
id = "quietHours"
|
|
190
224
|
|
|
@@ -209,6 +243,12 @@ class QuietHours(BaseCheck):
|
|
|
209
243
|
whose = "" if from_day == day else f" ({weekday_of(from_day)} {from_day})"
|
|
210
244
|
return reject(f"quiet hours {window.start} to {window.end}{whose} {ctx.user.timezone}; priority {ctx.priority} is below the floor ({self.floor})")
|
|
211
245
|
|
|
246
|
+
def next_allowed_at(self, ctx: Context) -> datetime | None:
|
|
247
|
+
qh = ctx.user.quiet_hours
|
|
248
|
+
if qh is None or not ctx.user.timezone:
|
|
249
|
+
return None
|
|
250
|
+
return quiet_ends_at(qh, ctx.user.timezone, ctx.now)
|
|
251
|
+
|
|
212
252
|
|
|
213
253
|
class TrustRamp(BaseCheck):
|
|
214
254
|
id = "trustRamp"
|
|
@@ -321,6 +361,17 @@ class Budget(BaseCheck):
|
|
|
321
361
|
return None
|
|
322
362
|
return ConsumePlan(self.key_for(ctx), self.ttl_seconds, self.limit)
|
|
323
363
|
|
|
364
|
+
def key_at(self, user_id: str, at: datetime, tz: str | None) -> str:
|
|
365
|
+
"""The counter key in force at ``at``; the reset is the first minute this answers differently."""
|
|
366
|
+
raise NotImplementedError
|
|
367
|
+
|
|
368
|
+
def next_allowed_at(self, ctx: Context) -> datetime | None:
|
|
369
|
+
user = ctx.user
|
|
370
|
+
try:
|
|
371
|
+
return next_key_change(ctx.now, user.timezone, lambda at: self.key_at(user.id, at, user.timezone))
|
|
372
|
+
except NotImplementedError:
|
|
373
|
+
return None
|
|
374
|
+
|
|
324
375
|
|
|
325
376
|
def budget_key(user_id: str, now: datetime, tz: str | None = None) -> str:
|
|
326
377
|
return f"budget:{user_id}:{local_day(now, tz)}"
|
|
@@ -343,6 +394,9 @@ class DailyBudget(Budget):
|
|
|
343
394
|
def key_for(self, ctx: Context) -> str:
|
|
344
395
|
return budget_key(ctx.user.id, ctx.now, ctx.user.timezone)
|
|
345
396
|
|
|
397
|
+
def key_at(self, user_id: str, at: datetime, tz: str | None) -> str:
|
|
398
|
+
return budget_key(user_id, at, tz)
|
|
399
|
+
|
|
346
400
|
|
|
347
401
|
class WeeklyBudget(Budget):
|
|
348
402
|
id = "weeklyBudget"
|
|
@@ -353,6 +407,9 @@ class WeeklyBudget(Budget):
|
|
|
353
407
|
def key_for(self, ctx: Context) -> str:
|
|
354
408
|
return weekly_budget_key(ctx.user.id, ctx.now, ctx.user.timezone)
|
|
355
409
|
|
|
410
|
+
def key_at(self, user_id: str, at: datetime, tz: str | None) -> str:
|
|
411
|
+
return weekly_budget_key(user_id, at, tz)
|
|
412
|
+
|
|
356
413
|
|
|
357
414
|
class MonthlyBudget(Budget):
|
|
358
415
|
id = "monthlyBudget"
|
|
@@ -363,6 +420,9 @@ class MonthlyBudget(Budget):
|
|
|
363
420
|
def key_for(self, ctx: Context) -> str:
|
|
364
421
|
return monthly_budget_key(ctx.user.id, ctx.now, ctx.user.timezone)
|
|
365
422
|
|
|
423
|
+
def key_at(self, user_id: str, at: datetime, tz: str | None) -> str:
|
|
424
|
+
return monthly_budget_key(user_id, at, tz)
|
|
425
|
+
|
|
366
426
|
|
|
367
427
|
class UtilityFloor(BaseCheck):
|
|
368
428
|
"""Act only when the caller's pAccept clears tau = cFA / (cFA + pNeed * cFN)."""
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Local-time arithmetic with the standard library only."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import UTC, date, datetime, timedelta
|
|
5
6
|
from zoneinfo import ZoneInfo
|
|
6
7
|
|
|
7
8
|
DAY_SECONDS = 24 * 60 * 60
|
|
@@ -54,3 +55,33 @@ def weekday_of(day: str) -> str:
|
|
|
54
55
|
|
|
55
56
|
def day_before(day: str) -> str:
|
|
56
57
|
return (date.fromisoformat(day) - timedelta(days=1)).isoformat()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_MINUTE = timedelta(minutes=1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _floor_minute(at: datetime) -> datetime:
|
|
64
|
+
return at.astimezone(UTC).replace(second=0, microsecond=0)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def next_key_change(now: datetime, tz: str | None, key_at: Callable[[datetime], str]) -> datetime | None:
|
|
68
|
+
"""The first minute after ``now`` at which ``key_at`` answers differently.
|
|
69
|
+
|
|
70
|
+
Found by jumping to successive local midnights, then stepping back a minute at a time to the
|
|
71
|
+
first minute of the new key, which is what a daylight-saving change before midnight needs.
|
|
72
|
+
``None`` when the key does not change within forty days.
|
|
73
|
+
"""
|
|
74
|
+
base = key_at(now)
|
|
75
|
+
start = _floor_minute(now)
|
|
76
|
+
at = start
|
|
77
|
+
for _ in range(40):
|
|
78
|
+
minutes = local_clock(at, tz)[0] if tz else at.hour * 60 + at.minute
|
|
79
|
+
at = at + timedelta(minutes=1440 - minutes)
|
|
80
|
+
if key_at(at) != base:
|
|
81
|
+
for _back in range(180):
|
|
82
|
+
earlier = at - _MINUTE
|
|
83
|
+
if earlier <= start or key_at(earlier) == base:
|
|
84
|
+
break
|
|
85
|
+
at = earlier
|
|
86
|
+
return at
|
|
87
|
+
return None
|
|
@@ -53,6 +53,8 @@ def _compare(failures: list[str], at: str, decision: Decision, expect: Mapping[s
|
|
|
53
53
|
check("rejectedBy", decision.rejected_by, expect.get("rejectedBy"))
|
|
54
54
|
check("deferredBy", decision.deferred_by, expect.get("deferredBy"))
|
|
55
55
|
check("retryAt", iso_z(decision.retry_at) if decision.retry_at else None, expect.get("retryAt"))
|
|
56
|
+
if "nextAllowedAt" in expect:
|
|
57
|
+
check("nextAllowedAt", iso_z(decision.next_allowed_at) if decision.next_allowed_at else None, expect["nextAllowedAt"])
|
|
56
58
|
if "surfaces" in expect:
|
|
57
59
|
check("surfaces", list(decision.surfaces), expect["surfaces"])
|
|
58
60
|
check("deliverAt", iso_z(decision.deliver_at) if decision.deliver_at else None, expect.get("deliverAt"))
|
|
@@ -140,10 +140,16 @@ class Evaluation:
|
|
|
140
140
|
self._maybe_finish()
|
|
141
141
|
return
|
|
142
142
|
if outcome.kind == "reject":
|
|
143
|
-
self._finish(rejected_by=check.id, reason=outcome.reason)
|
|
143
|
+
self._finish(rejected_by=check.id, reason=outcome.reason, next_allowed_at=self._hold_for(check, ctx))
|
|
144
144
|
return
|
|
145
145
|
if outcome.kind == "defer":
|
|
146
|
-
|
|
146
|
+
retry_at = outcome.retry_at
|
|
147
|
+
self._finish(
|
|
148
|
+
deferred_by=check.id,
|
|
149
|
+
retry_at=retry_at,
|
|
150
|
+
reason=outcome.reason,
|
|
151
|
+
next_allowed_at=retry_at if retry_at is not None and retry_at > self.now else None,
|
|
152
|
+
)
|
|
147
153
|
return
|
|
148
154
|
if outcome.kind == "adjust":
|
|
149
155
|
if outcome.deliver_at is not None:
|
|
@@ -152,6 +158,17 @@ class Evaluation:
|
|
|
152
158
|
self._surfaces = tuple(outcome.surfaces)
|
|
153
159
|
self._maybe_finish()
|
|
154
160
|
|
|
161
|
+
def _hold_for(self, check: Check, ctx: Context) -> datetime | None:
|
|
162
|
+
"""What the denying check says about when it would stop denying; a failure or a past instant is no answer."""
|
|
163
|
+
hook: Callable[[Context], datetime | None] | None = getattr(check, "next_allowed_at", None)
|
|
164
|
+
if hook is None:
|
|
165
|
+
return None
|
|
166
|
+
try:
|
|
167
|
+
at = hook(ctx)
|
|
168
|
+
return at if at is not None and at > self.now else None
|
|
169
|
+
except BaseException: # noqa: BLE001 - an answer that fails is no answer, never a failed decision
|
|
170
|
+
return None
|
|
171
|
+
|
|
155
172
|
def _maybe_finish(self) -> None:
|
|
156
173
|
if self._index >= len(self.checks):
|
|
157
174
|
self._finish(allowed=True)
|
|
@@ -163,6 +180,7 @@ class Evaluation:
|
|
|
163
180
|
deferred_by: str | None = None,
|
|
164
181
|
retry_at: datetime | None = None,
|
|
165
182
|
reason: str | None = None,
|
|
183
|
+
next_allowed_at: datetime | None = None,
|
|
166
184
|
) -> None:
|
|
167
185
|
user, candidate = self.input.user, self.input.candidate
|
|
168
186
|
self._result = Decision(
|
|
@@ -180,6 +198,7 @@ class Evaluation:
|
|
|
180
198
|
deferred_by=deferred_by,
|
|
181
199
|
retry_at=retry_at,
|
|
182
200
|
reason=reason,
|
|
201
|
+
next_allowed_at=next_allowed_at,
|
|
183
202
|
)
|
|
184
203
|
self._call("finally_", self._result)
|
|
185
204
|
|
|
@@ -228,6 +228,13 @@ class Decision:
|
|
|
228
228
|
deferred_by: str | None = None
|
|
229
229
|
retry_at: datetime | None = None
|
|
230
230
|
reason: str | None = None
|
|
231
|
+
next_allowed_at: datetime | None = None
|
|
232
|
+
"""Earliest instant at which the check that denied this candidate would stop denying it.
|
|
233
|
+
|
|
234
|
+
A lower bound, not a promise: a later check can still deny then. ``None`` when allowed, when
|
|
235
|
+
the denying check cannot say, and when a shadowed check would have denied. For a deferred
|
|
236
|
+
decision it equals ``retry_at``.
|
|
237
|
+
"""
|
|
231
238
|
|
|
232
239
|
|
|
233
240
|
@dataclass(frozen=True, slots=True)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""``Decision.next_allowed_at``: the gate takes it from the check that denied, and only then.
|
|
2
|
+
|
|
3
|
+
The shared fixtures under spec/fixtures/hold hold the instants; these tests cover what a
|
|
4
|
+
fixture cannot say: a check of your own, a hook that fails, and the brute-force comparison.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from proactive_gate import Candidate, Context, EvaluateInput, Gate, Outcome, UserState
|
|
14
|
+
from proactive_gate.checks import BaseCheck, budget_key, monthly_budget_key, quiet_at, quiet_ends_at, weekly_budget_key
|
|
15
|
+
from proactive_gate.clock import local_clock, next_key_change
|
|
16
|
+
from proactive_gate.types import QuietSchedule, QuietWindow, defer, reject
|
|
17
|
+
|
|
18
|
+
MINUTE = timedelta(minutes=1)
|
|
19
|
+
NOW = datetime(2026, 9, 4, 9, 0, tzinfo=UTC)
|
|
20
|
+
USER = UserState(id="u1", consent=True, timezone="Europe/Istanbul")
|
|
21
|
+
CANDIDATE = Candidate(id="c1", type="reminder")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Denies(BaseCheck):
|
|
25
|
+
"""Rejects every candidate and answers ``answer`` when asked when it would stop."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, id: str, answer: datetime | BaseException | None = None, shadow: bool = False) -> None:
|
|
28
|
+
self.id = id
|
|
29
|
+
self._answer = answer
|
|
30
|
+
self.shadow = shadow
|
|
31
|
+
self.calls = 0
|
|
32
|
+
|
|
33
|
+
def run(self, ctx: Context, values: object) -> Outcome:
|
|
34
|
+
return reject("no")
|
|
35
|
+
|
|
36
|
+
def next_allowed_at(self, ctx: Context) -> datetime | None:
|
|
37
|
+
self.calls += 1
|
|
38
|
+
if isinstance(self._answer, BaseException):
|
|
39
|
+
raise self._answer
|
|
40
|
+
return self._answer
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DeniesWithoutAnAnswer(BaseCheck):
|
|
44
|
+
"""Rejects, and has no ``next_allowed_at`` at all, like every check written before 1.5.0."""
|
|
45
|
+
|
|
46
|
+
id = "plain"
|
|
47
|
+
|
|
48
|
+
def run(self, ctx: Context, values: object) -> Outcome:
|
|
49
|
+
return reject("no")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Passes(BaseCheck):
|
|
53
|
+
id = "passes"
|
|
54
|
+
calls = 0
|
|
55
|
+
|
|
56
|
+
def next_allowed_at(self, ctx: Context) -> datetime | None:
|
|
57
|
+
self.calls += 1
|
|
58
|
+
return NOW + timedelta(days=1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def evaluate(checks: Sequence[BaseCheck], now: datetime = NOW) -> tuple[bool, str | None, datetime | None]:
|
|
62
|
+
decision = Gate(checks).evaluate(EvaluateInput(USER, CANDIDATE, now))
|
|
63
|
+
return decision.allowed, decision.rejected_by, decision.next_allowed_at
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_the_answer_comes_from_the_check_that_rejected_and_only_after_it_rejected() -> None:
|
|
67
|
+
first, second, third = Passes(), Denies("second", NOW + timedelta(hours=3)), Denies("third", NOW + timedelta(days=9))
|
|
68
|
+
allowed, by, answer = evaluate([first, second, third])
|
|
69
|
+
assert (allowed, by, answer) == (False, "second", NOW + timedelta(hours=3))
|
|
70
|
+
assert (first.calls, second.calls, third.calls) == (0, 1, 0)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
"answer",
|
|
75
|
+
[None, NOW, NOW - timedelta(hours=1), RuntimeError("boom")],
|
|
76
|
+
ids=["none", "now itself", "the past", "a raise"],
|
|
77
|
+
)
|
|
78
|
+
def test_an_answer_that_is_none_not_after_now_or_raises_is_no_answer(answer: datetime | BaseException | None) -> None:
|
|
79
|
+
allowed, by, got = evaluate([Denies("c", answer)])
|
|
80
|
+
assert (allowed, by, got) == (False, "c", None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_allowed_shadowed_and_unanswering_checks_carry_none() -> None:
|
|
84
|
+
assert evaluate([Denies("s", NOW + MINUTE, shadow=True)]) == (True, None, None)
|
|
85
|
+
assert evaluate([DeniesWithoutAnAnswer()]) == (False, "plain", None)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_a_naive_datetime_answer_is_no_answer_and_the_decision_is_still_produced() -> None:
|
|
89
|
+
naive = datetime(2026, 9, 4, 12, 0)
|
|
90
|
+
decision = Gate([Denies("c", naive)]).evaluate(EvaluateInput(USER, CANDIDATE, NOW))
|
|
91
|
+
assert (decision.allowed, decision.rejected_by, decision.next_allowed_at) == (False, "c", None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Deferring(BaseCheck):
|
|
95
|
+
id = "d"
|
|
96
|
+
|
|
97
|
+
def __init__(self, retry_at: datetime) -> None:
|
|
98
|
+
self.retry_at = retry_at
|
|
99
|
+
|
|
100
|
+
def run(self, ctx: Context, values: object) -> Outcome:
|
|
101
|
+
return defer("later", self.retry_at)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_a_deferred_decision_carries_retry_at_as_its_answer() -> None:
|
|
105
|
+
until = NOW + timedelta(hours=1)
|
|
106
|
+
decision = Gate([Deferring(until)]).evaluate(EvaluateInput(USER, CANDIDATE, NOW))
|
|
107
|
+
assert decision.next_allowed_at == until == decision.retry_at
|
|
108
|
+
past = Gate([Deferring(NOW - MINUTE)]).evaluate(EvaluateInput(USER, CANDIDATE, NOW))
|
|
109
|
+
assert past.next_allowed_at is None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def first_open_minute(quiet: QuietWindow | QuietSchedule, zone: str, now: datetime) -> datetime | None:
|
|
113
|
+
at = now.astimezone(UTC).replace(second=0, microsecond=0)
|
|
114
|
+
for _ in range(60 * 48):
|
|
115
|
+
at += MINUTE
|
|
116
|
+
minutes, day = local_clock(at, zone)
|
|
117
|
+
if quiet_at(quiet, day, minutes) is None:
|
|
118
|
+
return at
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_quiet_ends_at_equals_a_minute_by_minute_scan_across_daylight_saving_changes() -> None:
|
|
123
|
+
zones = ["America/New_York", "Europe/London", "Australia/Lord_Howe", "Asia/Kathmandu", "America/Havana"]
|
|
124
|
+
windows = [QuietWindow("22:00", "08:00"), QuietWindow("22:00", "02:30"), QuietWindow("23:30", "01:30"), QuietWindow("13:00", "14:00")]
|
|
125
|
+
starts = [datetime(2026, 3, 7, tzinfo=UTC), datetime(2026, 3, 28, tzinfo=UTC), datetime(2026, 10, 3, tzinfo=UTC), datetime(2026, 10, 31, tzinfo=UTC)]
|
|
126
|
+
compared = 0
|
|
127
|
+
for zone in zones:
|
|
128
|
+
for window in windows:
|
|
129
|
+
for start in starts:
|
|
130
|
+
for step in range(0, 48 * 60, 71):
|
|
131
|
+
now = start + timedelta(minutes=step, seconds=17)
|
|
132
|
+
minutes, day = local_clock(now, zone)
|
|
133
|
+
if quiet_at(window, day, minutes) is None:
|
|
134
|
+
continue
|
|
135
|
+
assert quiet_ends_at(window, zone, now) == first_open_minute(window, zone, now), (zone, window, now)
|
|
136
|
+
compared += 1
|
|
137
|
+
assert compared > 100
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_quiet_ends_at_inside_the_second_pass_of_a_repeated_hour() -> None:
|
|
141
|
+
# 2026-11-01 01:00-02:00 happens twice in New York; 06:10 UTC is 01:10 EST, the second pass.
|
|
142
|
+
window = QuietWindow("23:00", "01:30")
|
|
143
|
+
now = datetime(2026, 11, 1, 6, 10, tzinfo=UTC)
|
|
144
|
+
expected = datetime(2026, 11, 1, 6, 30, tzinfo=UTC)
|
|
145
|
+
assert quiet_ends_at(window, "America/New_York", now) == expected
|
|
146
|
+
assert first_open_minute(window, "America/New_York", now) == expected
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_quiet_ends_at_follows_a_schedule_and_gives_up_on_a_window_that_never_opens() -> None:
|
|
150
|
+
chain = QuietSchedule(days={"fri": QuietWindow("18:00", "00:00"), "sat": QuietWindow("00:00", "20:00")})
|
|
151
|
+
assert quiet_ends_at(chain, "Europe/Istanbul", datetime(2026, 9, 4, 16, 0, tzinfo=UTC)) == datetime(2026, 9, 5, 17, 0, tzinfo=UTC)
|
|
152
|
+
all_day = QuietSchedule(default=QuietWindow("00:00", "24:00"))
|
|
153
|
+
assert quiet_ends_at(all_day, "Europe/Istanbul", datetime(2026, 9, 4, 16, 0, tzinfo=UTC)) is None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
KeyFor = Callable[[str, datetime, str | None], str]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def assert_resets_at_the_first_new_key(zone: str, now: datetime, key_for: KeyFor) -> None:
|
|
160
|
+
found = next_key_change(now, zone, lambda at: key_for("u", at, zone))
|
|
161
|
+
assert found is not None, (zone, now)
|
|
162
|
+
assert key_for("u", found, zone) != key_for("u", now, zone)
|
|
163
|
+
assert key_for("u", found - MINUTE, zone) == key_for("u", now, zone)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_next_key_change_is_the_first_minute_whose_key_differs() -> None:
|
|
167
|
+
assert_resets_at_the_first_new_key("America/Havana", datetime(2026, 3, 7, 20, 0, tzinfo=UTC), budget_key)
|
|
168
|
+
assert_resets_at_the_first_new_key("America/New_York", datetime(2026, 11, 1, 4, 30, tzinfo=UTC), budget_key)
|
|
169
|
+
assert_resets_at_the_first_new_key("America/New_York", datetime(2026, 3, 8, 5, 30, tzinfo=UTC), budget_key)
|
|
170
|
+
assert_resets_at_the_first_new_key("Pacific/Apia", datetime(2011, 12, 29, 10, 0, tzinfo=UTC), budget_key)
|
|
171
|
+
assert_resets_at_the_first_new_key("Europe/Istanbul", datetime(2026, 9, 2, 9, 0, tzinfo=UTC), weekly_budget_key)
|
|
172
|
+
assert_resets_at_the_first_new_key("Europe/Istanbul", datetime(2026, 9, 15, 9, 0, tzinfo=UTC), monthly_budget_key)
|
|
173
|
+
assert_resets_at_the_first_new_key("America/New_York", datetime(2026, 12, 31, 20, 0, tzinfo=UTC), monthly_budget_key)
|
|
174
|
+
utc = next_key_change(datetime(2026, 9, 4, 9, 0, 30, tzinfo=UTC), None, lambda at: budget_key("u", at, None))
|
|
175
|
+
assert utc == datetime(2026, 9, 5, tzinfo=UTC)
|
|
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
|
|
File without changes
|
|
File without changes
|