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.
- proactive_gate/__init__.py +31 -0
- proactive_gate/checks.py +522 -0
- proactive_gate/clock.py +40 -0
- proactive_gate/conformance.py +134 -0
- proactive_gate/gate.py +395 -0
- proactive_gate/policy.py +94 -0
- proactive_gate/presets.py +128 -0
- proactive_gate/py.typed +0 -0
- proactive_gate/stores.py +168 -0
- proactive_gate/types.py +221 -0
- proactive_gate-0.2.1.dist-info/METADATA +131 -0
- proactive_gate-0.2.1.dist-info/RECORD +13 -0
- proactive_gate-0.2.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Platform quotas and legal limits as ordered check lists, one to one with the
|
|
2
|
+
TypeScript presets. Reviewable defaults, not legal advice; every number sits
|
|
3
|
+
next to its source."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from . import checks as c
|
|
11
|
+
from .checks import Check
|
|
12
|
+
from .types import Context, Outcome
|
|
13
|
+
|
|
14
|
+
Options = Mapping[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Preset:
|
|
19
|
+
build: Callable[[Options], list[Check]]
|
|
20
|
+
sources: tuple[str, ...]
|
|
21
|
+
note: str
|
|
22
|
+
|
|
23
|
+
def __call__(self, options: Options | None = None) -> list[Check]:
|
|
24
|
+
return self.build(options or {})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
LINE_PLANS: dict[str, int] = {"communication": 200, "light": 5000, "standard": 30000}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _line(o: Options) -> list[Check]:
|
|
31
|
+
plan = o.get("plan", "communication")
|
|
32
|
+
if plan not in LINE_PLANS:
|
|
33
|
+
raise ValueError(f'lineMessagingApi: unknown plan "{plan}", known: {", ".join(LINE_PLANS)}')
|
|
34
|
+
return [c.Consent(), c.MonthlyBudget(limit=LINE_PLANS[plan], near_limit=0.9)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cn_minor_mode(o: Options) -> list[Check]:
|
|
38
|
+
is_minor = lambda ctx: bool(ctx.user.minor) # noqa: E731
|
|
39
|
+
window = c.AllowedWindow("06:00", "22:00", "Asia/Shanghai", id="window:minor")
|
|
40
|
+
return [c.OnlyWhen(window, is_minor, "not a minor"), c.OnlyWhen(c.DailyBudget(limit=1), is_minor, "not a minor")]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _SoftOptIn(c.BaseCheck):
|
|
44
|
+
id = "consent:marketing"
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.inner = c.RequiresConsent("marketing")
|
|
48
|
+
|
|
49
|
+
def run(self, ctx: Context, values: Mapping[str, str | None]) -> Outcome:
|
|
50
|
+
if ctx.user.existing_customer:
|
|
51
|
+
return Outcome("pass", "existing customer, soft opt-in")
|
|
52
|
+
return self.inner.run(ctx, values)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
presets: dict[str, Preset] = {
|
|
56
|
+
"lineMessagingApi": Preset(
|
|
57
|
+
_line,
|
|
58
|
+
("https://developers.line.biz/en/docs/messaging-api/pricing/", "https://developers.line.biz/en/reference/messaging-api/"),
|
|
59
|
+
"Monthly push messages per plan for Japan: communication 200, light 5,000, standard 30,000; replies are free and not counted. Multicast and broadcast request rates are not encoded.",
|
|
60
|
+
),
|
|
61
|
+
"wechatSubscriptionMessage": Preset(
|
|
62
|
+
lambda o: [c.RequiresConsent("subscription"), c.WindowBudget(limit=1, within_hours=24 * 365)],
|
|
63
|
+
("https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/subscribe-message-overview.html",),
|
|
64
|
+
"One-time subscription: exactly one message per opt-in; set user.lastInboundAt to the opt-in instant. Long-term subscriptions for government, medical, transport, finance and education categories are not encoded.",
|
|
65
|
+
),
|
|
66
|
+
"wechatCustomerService": Preset(
|
|
67
|
+
lambda o: [c.RecentInteraction(within_hours=48), c.WindowBudget(limit=5, within_hours=48)],
|
|
68
|
+
("https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/customer-message/send.html",),
|
|
69
|
+
"Mini program customer-service messages: within 48 hours of the user's last message, at most 5 in that window.",
|
|
70
|
+
),
|
|
71
|
+
"wechatTemplateMessage": Preset(
|
|
72
|
+
lambda o: [c.RequiresConsent("templateTrigger"), c.RateLimit(limit=3, per_seconds=24 * 3600, key_by="user", id="rate:template")],
|
|
73
|
+
("https://developers.weixin.qq.com/doc/service/guide/product/template_message/Template_Message_Operation_Specifications.html",),
|
|
74
|
+
"Template messages only after a user action (consents.templateTrigger) and no more than three repeated templates a day; marketing templates are not allowed at all.",
|
|
75
|
+
),
|
|
76
|
+
"wecomAppMessage": Preset(
|
|
77
|
+
lambda o: [c.RateLimit(limit=30, per_seconds=60, id="rate:30/min"), c.RateLimit(limit=1000, per_seconds=3600, id="rate:1000/h")],
|
|
78
|
+
("https://developer.work.weixin.qq.com/document/path/96212",),
|
|
79
|
+
"WeCom application messages per app per member: 30 a minute and 1,000 an hour; the platform drops the excess silently, this preset refuses it with a reason.",
|
|
80
|
+
),
|
|
81
|
+
"kakaoAlimtalk": Preset(
|
|
82
|
+
lambda o: [c.Consent()],
|
|
83
|
+
("https://kakaobusiness.gitbook.io/main/ad/infotalk",),
|
|
84
|
+
"AlimTalk is informational and carries no time-of-day limit; consent is the only gate.",
|
|
85
|
+
),
|
|
86
|
+
"kakaoBrandMessage": Preset(
|
|
87
|
+
lambda o: [c.RequiresConsent("ad"), c.AllowedWindow("08:00", "20:50", "Asia/Seoul", id="window:kakao")],
|
|
88
|
+
("https://kakaobusiness.gitbook.io/main/ad/moment/messagead/channelmessage/new/send",),
|
|
89
|
+
"Brand messages need advertising consent and go out 08:00 to 20:50 Korea time regardless of the recipient's location. Official sources also quote 20:00 and 20:55; 20:50 is the stricter documented value.",
|
|
90
|
+
),
|
|
91
|
+
"krNetworkAct50": Preset(
|
|
92
|
+
lambda o: [c.RequiresConsent("ad"), c.RequiresConsent("night", when={"start": "21:00", "end": "08:00", "timezone": "user"})],
|
|
93
|
+
("https://www.law.go.kr", "https://developers.fingerpush.com/biz-message/console/ads-guide"),
|
|
94
|
+
"Network Act article 50: prior consent for advertising, and a separate consent for 21:00 to 08:00 (email is exempt). The two-year re-confirmation is not encoded.",
|
|
95
|
+
),
|
|
96
|
+
"jpAntiSpamLaw": Preset(
|
|
97
|
+
lambda o: [c.RequiresConsent("optIn")],
|
|
98
|
+
("https://www.soumu.go.jp/main_sosiki/cybersecurity/kokumin/basic/legal/08/",),
|
|
99
|
+
"Opt-in since 2008 with sender identity and an opt-out route. There is no time-of-day rule in the law; a Japanese quiet-hours window would be etiquette, so none is encoded.",
|
|
100
|
+
),
|
|
101
|
+
"cnMinorMode": Preset(
|
|
102
|
+
_cn_minor_mode,
|
|
103
|
+
("https://www.cac.gov.cn/2024-11/15/c_1733364304749288.htm", "https://www.cac.gov.cn/2022-01/04/c_1642894606364259.htm"),
|
|
104
|
+
"Minor mode: no service 22:00 to 06:00 China time and a daily budget of one when user.minor is true; adults pass both checks. Per-age daily durations are not encoded.",
|
|
105
|
+
),
|
|
106
|
+
"usTcpa": Preset(
|
|
107
|
+
lambda o: [c.AllowedWindow("08:00", "21:00", "user", id="window:tcpa")],
|
|
108
|
+
("https://www.law.cornell.edu/cfr/text/47/64.1200",),
|
|
109
|
+
"47 CFR 64.1200: no solicitation before 8 a.m. or after 9 p.m. at the called party's local time.",
|
|
110
|
+
),
|
|
111
|
+
"euEprivacy": Preset(
|
|
112
|
+
lambda o: [_SoftOptIn()],
|
|
113
|
+
("https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32002L0058",),
|
|
114
|
+
"Directive 2002/58/EC article 13: prior consent for direct marketing, with the soft opt-in for existing customers (user.existingCustomer).",
|
|
115
|
+
),
|
|
116
|
+
"telegramBot": Preset(
|
|
117
|
+
lambda o: [c.RateLimit(limit=1, per_seconds=1, key_by="channel", id="rate:1/s"), c.RateLimit(limit=20, per_seconds=60, key_by="channel", id="rate:20/min")],
|
|
118
|
+
("https://core.telegram.org/bots/faq",),
|
|
119
|
+
"One message a second per chat and twenty a minute per group, keyed by candidate.channel. The broadcast rate of roughly thirty a second is not encoded.",
|
|
120
|
+
),
|
|
121
|
+
"slackApp": Preset(
|
|
122
|
+
lambda o: [c.RateLimit(limit=1, per_seconds=1, key_by="channel", id="rate:1/s")],
|
|
123
|
+
("https://docs.slack.dev/apis/web-api/rate-limits/",),
|
|
124
|
+
"chat.postMessage: one message a second per channel, keyed by candidate.channel.",
|
|
125
|
+
),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
__all__ = ["LINE_PLANS", "Preset", "presets"]
|
proactive_gate/py.typed
ADDED
|
File without changes
|
proactive_gate/stores.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Key-value stores. Sync ``Store`` for ``Gate``; ``AsyncStore`` for ``AsyncGate``.
|
|
2
|
+
Every method may raise; the gate decides whether that fails open or closed."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Store(Protocol):
|
|
12
|
+
def get(self, key: str) -> str | None: ...
|
|
13
|
+
|
|
14
|
+
def set(self, key: str, value: str, ttl_seconds: int | None = None) -> None: ...
|
|
15
|
+
|
|
16
|
+
def incr(self, key: str, ttl_seconds: int | None = None) -> int: ...
|
|
17
|
+
|
|
18
|
+
def delete(self, key: str) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncStore(Protocol):
|
|
22
|
+
async def get(self, key: str) -> str | None: ...
|
|
23
|
+
|
|
24
|
+
async def set(self, key: str, value: str, ttl_seconds: int | None = None) -> None: ...
|
|
25
|
+
|
|
26
|
+
async def incr(self, key: str, ttl_seconds: int | None = None) -> int: ...
|
|
27
|
+
|
|
28
|
+
async def delete(self, key: str) -> None: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MemoryStore:
|
|
32
|
+
"""In-process store with TTLs. Good for tests and single-process deployments."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._data: dict[str, tuple[str, float | None]] = {}
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
|
|
38
|
+
def _live(self, key: str) -> str | None:
|
|
39
|
+
entry = self._data.get(key)
|
|
40
|
+
if entry is None:
|
|
41
|
+
return None
|
|
42
|
+
value, expires = entry
|
|
43
|
+
if expires is not None and time.time() >= expires:
|
|
44
|
+
del self._data[key]
|
|
45
|
+
return None
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
def get(self, key: str) -> str | None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
return self._live(key)
|
|
51
|
+
|
|
52
|
+
def set(self, key: str, value: str, ttl_seconds: int | None = None) -> None:
|
|
53
|
+
with self._lock:
|
|
54
|
+
self._data[key] = (value, time.time() + ttl_seconds if ttl_seconds else None)
|
|
55
|
+
|
|
56
|
+
def incr(self, key: str, ttl_seconds: int | None = None) -> int:
|
|
57
|
+
with self._lock:
|
|
58
|
+
current = self._live(key)
|
|
59
|
+
next_value = int(current or 0) + 1
|
|
60
|
+
expires = self._data[key][1] if current is not None else (time.time() + ttl_seconds if ttl_seconds else None)
|
|
61
|
+
self._data[key] = (str(next_value), expires)
|
|
62
|
+
return next_value
|
|
63
|
+
|
|
64
|
+
def delete(self, key: str) -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
self._data.pop(key, None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SqliteStore:
|
|
70
|
+
"""Standard-library sqlite3 store; one file shared by every process on the host."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, path: str = ":memory:") -> None:
|
|
73
|
+
self._conn = sqlite3.connect(path, isolation_level=None, check_same_thread=False)
|
|
74
|
+
self._conn.execute("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at REAL)")
|
|
75
|
+
self._lock = threading.Lock()
|
|
76
|
+
|
|
77
|
+
def _row(self, key: str) -> str | None:
|
|
78
|
+
row = self._conn.execute("SELECT value, expires_at FROM kv WHERE key = ?", (key,)).fetchone()
|
|
79
|
+
if row is None:
|
|
80
|
+
return None
|
|
81
|
+
value, expires = row
|
|
82
|
+
if expires is not None and time.time() >= expires:
|
|
83
|
+
self._conn.execute("DELETE FROM kv WHERE key = ?", (key,))
|
|
84
|
+
return None
|
|
85
|
+
return str(value)
|
|
86
|
+
|
|
87
|
+
def get(self, key: str) -> str | None:
|
|
88
|
+
with self._lock:
|
|
89
|
+
return self._row(key)
|
|
90
|
+
|
|
91
|
+
def set(self, key: str, value: str, ttl_seconds: int | None = None) -> None:
|
|
92
|
+
expires = time.time() + ttl_seconds if ttl_seconds else None
|
|
93
|
+
with self._lock:
|
|
94
|
+
self._conn.execute("INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)", (key, value, expires))
|
|
95
|
+
|
|
96
|
+
def incr(self, key: str, ttl_seconds: int | None = None) -> int:
|
|
97
|
+
with self._lock:
|
|
98
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
99
|
+
try:
|
|
100
|
+
current = self._row(key)
|
|
101
|
+
next_value = int(current or 0) + 1
|
|
102
|
+
if current is None:
|
|
103
|
+
expires = time.time() + ttl_seconds if ttl_seconds else None
|
|
104
|
+
self._conn.execute("INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)", (key, str(next_value), expires))
|
|
105
|
+
else:
|
|
106
|
+
self._conn.execute("UPDATE kv SET value = ? WHERE key = ?", (str(next_value), key))
|
|
107
|
+
self._conn.execute("COMMIT")
|
|
108
|
+
except BaseException:
|
|
109
|
+
self._conn.execute("ROLLBACK")
|
|
110
|
+
raise
|
|
111
|
+
return next_value
|
|
112
|
+
|
|
113
|
+
def delete(self, key: str) -> None:
|
|
114
|
+
with self._lock:
|
|
115
|
+
self._conn.execute("DELETE FROM kv WHERE key = ?", (key,))
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
self._conn.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AsyncMemoryStore:
|
|
122
|
+
"""``MemoryStore`` behind the async protocol, for ``AsyncGate`` tests."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, inner: MemoryStore | None = None) -> None:
|
|
125
|
+
self.inner = inner or MemoryStore()
|
|
126
|
+
|
|
127
|
+
async def get(self, key: str) -> str | None:
|
|
128
|
+
return self.inner.get(key)
|
|
129
|
+
|
|
130
|
+
async def set(self, key: str, value: str, ttl_seconds: int | None = None) -> None:
|
|
131
|
+
self.inner.set(key, value, ttl_seconds)
|
|
132
|
+
|
|
133
|
+
async def incr(self, key: str, ttl_seconds: int | None = None) -> int:
|
|
134
|
+
return self.inner.incr(key, ttl_seconds)
|
|
135
|
+
|
|
136
|
+
async def delete(self, key: str) -> None:
|
|
137
|
+
self.inner.delete(key)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class RedisStore:
|
|
141
|
+
"""Wraps a ``redis.asyncio`` client. INCR, then EXPIRE on the first increment so a key never lives forever."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, client: Any) -> None:
|
|
144
|
+
self.client = client
|
|
145
|
+
|
|
146
|
+
async def get(self, key: str) -> str | None:
|
|
147
|
+
value = await self.client.get(key)
|
|
148
|
+
if value is None:
|
|
149
|
+
return None
|
|
150
|
+
return value.decode() if isinstance(value, bytes) else str(value)
|
|
151
|
+
|
|
152
|
+
async def set(self, key: str, value: str, ttl_seconds: int | None = None) -> None:
|
|
153
|
+
if ttl_seconds:
|
|
154
|
+
await self.client.set(key, value, ex=ttl_seconds)
|
|
155
|
+
else:
|
|
156
|
+
await self.client.set(key, value)
|
|
157
|
+
|
|
158
|
+
async def incr(self, key: str, ttl_seconds: int | None = None) -> int:
|
|
159
|
+
value = int(await self.client.incr(key))
|
|
160
|
+
if value == 1 and ttl_seconds:
|
|
161
|
+
await self.client.expire(key, ttl_seconds)
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
async def delete(self, key: str) -> None:
|
|
165
|
+
await self.client.delete(key)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
__all__ = ["AsyncMemoryStore", "AsyncStore", "MemoryStore", "RedisStore", "SqliteStore", "Store"]
|
proactive_gate/types.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Frozen data the gate reads and writes. Field names are snake_case; the JSON
|
|
2
|
+
fixtures and policies use the camelCase of the TypeScript package, and
|
|
3
|
+
``from_dict`` maps between them."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Literal, Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
Priority = Literal["low", "normal", "high", "critical"]
|
|
11
|
+
PRIORITY_RANK: dict[str, int] = {"low": 0, "normal": 1, "high": 2, "critical": 3}
|
|
12
|
+
Intensity = Literal["low", "normal", "high"]
|
|
13
|
+
OutcomeKind = Literal["pass", "reject", "adjust", "skip", "defer"]
|
|
14
|
+
OutcomeEvent = Literal["delivered", "dismissed", "acted", "ignored"]
|
|
15
|
+
StoreErrorMode = Literal["open", "closed"]
|
|
16
|
+
|
|
17
|
+
UTC = timezone.utc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def to_datetime(value: object) -> datetime | None:
|
|
21
|
+
"""A datetime, an ISO-8601 string (``Z`` accepted) or epoch milliseconds; anything else is None."""
|
|
22
|
+
if value is None:
|
|
23
|
+
return None
|
|
24
|
+
if isinstance(value, datetime):
|
|
25
|
+
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
26
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
27
|
+
return datetime.fromtimestamp(value / 1000, tz=UTC)
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
try:
|
|
30
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
31
|
+
except ValueError:
|
|
32
|
+
return None
|
|
33
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def iso_z(value: datetime) -> str:
|
|
38
|
+
"""The JavaScript ``toISOString`` form: UTC, milliseconds, trailing Z."""
|
|
39
|
+
utc = value.astimezone(UTC)
|
|
40
|
+
return f"{utc.strftime('%Y-%m-%dT%H:%M:%S')}.{utc.microsecond // 1000:03d}Z"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def epoch_ms(value: datetime) -> int:
|
|
44
|
+
return int(round(value.timestamp() * 1000))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class QuietHours:
|
|
49
|
+
start: str
|
|
50
|
+
end: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class UserState:
|
|
55
|
+
id: str
|
|
56
|
+
consent: bool
|
|
57
|
+
proactive_enabled: bool | None = None
|
|
58
|
+
mode: str | None = None
|
|
59
|
+
snoozed_until: datetime | None = None
|
|
60
|
+
muted_types: tuple[str, ...] = ()
|
|
61
|
+
intensity: Intensity | None = None
|
|
62
|
+
timezone: str | None = None
|
|
63
|
+
quiet_hours: QuietHours | None = None
|
|
64
|
+
created_at: datetime | None = None
|
|
65
|
+
surfaces: tuple[str, ...] | None = None
|
|
66
|
+
consents: Mapping[str, bool] = field(default_factory=dict)
|
|
67
|
+
last_inbound_at: datetime | None = None
|
|
68
|
+
minor: bool | None = None
|
|
69
|
+
existing_customer: bool | None = None
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def from_dict(data: Mapping[str, Any]) -> "UserState":
|
|
73
|
+
qh = data.get("quietHours")
|
|
74
|
+
return UserState(
|
|
75
|
+
id=str(data["id"]),
|
|
76
|
+
consent=bool(data.get("consent", False)),
|
|
77
|
+
proactive_enabled=data.get("proactiveEnabled"),
|
|
78
|
+
mode=data.get("mode"),
|
|
79
|
+
snoozed_until=to_datetime(data.get("snoozedUntil")),
|
|
80
|
+
muted_types=tuple(data.get("mutedTypes") or ()),
|
|
81
|
+
intensity=data.get("intensity"),
|
|
82
|
+
timezone=data.get("timezone"),
|
|
83
|
+
quiet_hours=QuietHours(str(qh["start"]), str(qh["end"])) if qh else None,
|
|
84
|
+
created_at=to_datetime(data.get("createdAt")),
|
|
85
|
+
surfaces=tuple(data["surfaces"]) if data.get("surfaces") is not None else None,
|
|
86
|
+
consents=dict(data.get("consents") or {}),
|
|
87
|
+
last_inbound_at=to_datetime(data.get("lastInboundAt")),
|
|
88
|
+
minor=data.get("minor"),
|
|
89
|
+
existing_customer=data.get("existingCustomer"),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class Candidate:
|
|
95
|
+
id: str
|
|
96
|
+
type: str
|
|
97
|
+
priority: Priority = "normal"
|
|
98
|
+
surfaces: tuple[str, ...] = ("feed",)
|
|
99
|
+
channel: str | None = None
|
|
100
|
+
busy: bool | None = None
|
|
101
|
+
p_accept: float | None = None
|
|
102
|
+
p_need: float | None = None
|
|
103
|
+
payload: Any = None
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def from_dict(data: Mapping[str, Any]) -> "Candidate":
|
|
107
|
+
return Candidate(
|
|
108
|
+
id=str(data["id"]),
|
|
109
|
+
type=str(data["type"]),
|
|
110
|
+
priority=data.get("priority") or "normal",
|
|
111
|
+
surfaces=tuple(data.get("surfaces") or ("feed",)),
|
|
112
|
+
channel=data.get("channel"),
|
|
113
|
+
busy=data.get("busy"),
|
|
114
|
+
p_accept=data.get("pAccept"),
|
|
115
|
+
p_need=data.get("pNeed"),
|
|
116
|
+
payload=data.get("payload"),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class NearLimit:
|
|
122
|
+
used: int
|
|
123
|
+
limit: int
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class Outcome:
|
|
128
|
+
"""What a single check says. ``kind`` decides which other fields matter."""
|
|
129
|
+
|
|
130
|
+
kind: OutcomeKind
|
|
131
|
+
reason: str | None = None
|
|
132
|
+
retry_at: datetime | None = None
|
|
133
|
+
deliver_at: datetime | None = None
|
|
134
|
+
surfaces: tuple[str, ...] | None = None
|
|
135
|
+
near_limit: NearLimit | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
PASS = Outcome("pass")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def reject(reason: str) -> Outcome:
|
|
142
|
+
return Outcome("reject", reason)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def skip(reason: str) -> Outcome:
|
|
146
|
+
return Outcome("skip", reason)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def defer(reason: str, retry_at: datetime) -> Outcome:
|
|
150
|
+
return Outcome("defer", reason, retry_at=retry_at)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True, slots=True)
|
|
154
|
+
class Context:
|
|
155
|
+
user: UserState
|
|
156
|
+
candidate: Candidate
|
|
157
|
+
now: datetime
|
|
158
|
+
priority: Priority
|
|
159
|
+
surfaces: tuple[str, ...]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class TraceEntry:
|
|
164
|
+
id: str
|
|
165
|
+
outcome: OutcomeKind
|
|
166
|
+
ms: float
|
|
167
|
+
reason: str | None = None
|
|
168
|
+
shadow: bool = False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True, slots=True)
|
|
172
|
+
class NearLimitNote:
|
|
173
|
+
check: str
|
|
174
|
+
used: int
|
|
175
|
+
limit: int
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True, slots=True)
|
|
179
|
+
class Decision:
|
|
180
|
+
id: str
|
|
181
|
+
allowed: bool
|
|
182
|
+
user_id: str
|
|
183
|
+
candidate_id: str
|
|
184
|
+
surfaces: tuple[str, ...]
|
|
185
|
+
trace: tuple[TraceEntry, ...]
|
|
186
|
+
evaluated_at: datetime
|
|
187
|
+
shadowed: tuple[str, ...] = ()
|
|
188
|
+
near_limit: tuple[NearLimitNote, ...] = ()
|
|
189
|
+
deliver_at: datetime | None = None
|
|
190
|
+
rejected_by: str | None = None
|
|
191
|
+
deferred_by: str | None = None
|
|
192
|
+
retry_at: datetime | None = None
|
|
193
|
+
reason: str | None = None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True, slots=True)
|
|
197
|
+
class EvaluateInput:
|
|
198
|
+
user: UserState
|
|
199
|
+
candidate: Candidate
|
|
200
|
+
now: datetime | None = None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True, slots=True)
|
|
204
|
+
class ConsumePlan:
|
|
205
|
+
"""A budget-like check's commit-time increment: the key, its TTL and the limit the new value must not exceed."""
|
|
206
|
+
|
|
207
|
+
key: str
|
|
208
|
+
ttl_seconds: int
|
|
209
|
+
limit: int
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def at_least(priority: str, floor: str) -> bool:
|
|
213
|
+
return PRIORITY_RANK[priority] >= PRIORITY_RANK[floor]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def surfaces_for(user: UserState, candidate: Candidate) -> tuple[str, ...]:
|
|
217
|
+
wanted: Sequence[str] = candidate.surfaces or ("feed",)
|
|
218
|
+
if user.surfaces is None:
|
|
219
|
+
return tuple(wanted)
|
|
220
|
+
allowed = set(user.surfaces)
|
|
221
|
+
return tuple(s for s in wanted if s in allowed)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: proactive-gate
|
|
3
|
+
Version: 0.2.1
|
|
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
|
+
Project-URL: Homepage, https://bubblegunn.github.io/proactive-gate/
|
|
6
|
+
Project-URL: Repository, https://github.com/Bubblegunn/proactive-gate
|
|
7
|
+
Project-URL: Issues, https://github.com/Bubblegunn/proactive-gate/issues
|
|
8
|
+
Project-URL: Specification, https://github.com/Bubblegunn/proactive-gate/tree/main/spec
|
|
9
|
+
Project-URL: Changelog, https://github.com/Bubblegunn/proactive-gate/blob/main/CHANGELOG.md
|
|
10
|
+
Author: Efe Genc
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
Keywords: agents,notifications,policy,proactive,quiet-hours,rate-limit
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Communications
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: redis>=5; extra == 'dev'
|
|
28
|
+
Provides-Extra: redis
|
|
29
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# proactive-gate for Python
|
|
33
|
+
|
|
34
|
+
Decide whether a proactive assistant may speak to this person right now. Ordered checks
|
|
35
|
+
(consent, mode, snooze, mute, intensity, quiet hours, trust ramp, dismissal cooldown,
|
|
36
|
+
budgets), JSON policies, platform and regulatory presets, and a trace that says which check
|
|
37
|
+
stopped the message and why.
|
|
38
|
+
|
|
39
|
+
This is the Python sibling of the TypeScript package. Both implement the same behaviour
|
|
40
|
+
contract in [`spec/`](https://github.com/Bubblegunn/proactive-gate/tree/main/spec) and run
|
|
41
|
+
the same fixtures, so a policy written for one behaves the same in the other.
|
|
42
|
+
|
|
43
|
+
Until the first PyPI release, install from the repository:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
git clone https://github.com/Bubblegunn/proactive-gate
|
|
47
|
+
pip install ./proactive-gate/python
|
|
48
|
+
```
|
|
49
|
+
|
|
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.
|
|
54
|
+
|
|
55
|
+
## Use
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from datetime import datetime, timezone
|
|
59
|
+
from proactive_gate import Candidate, EvaluateInput, Gate, UserState, default_checks
|
|
60
|
+
|
|
61
|
+
gate = Gate(default_checks(daily_limit=3))
|
|
62
|
+
user = UserState.from_dict({
|
|
63
|
+
"id": "u1", "consent": True, "timezone": "Europe/Istanbul",
|
|
64
|
+
"quietHours": {"start": "22:00", "end": "08:00"}, "createdAt": "2026-01-01T00:00:00Z",
|
|
65
|
+
})
|
|
66
|
+
candidate = Candidate(id="c1", type="reminder", priority="normal", surfaces=("push", "feed"))
|
|
67
|
+
inp = EvaluateInput(user, candidate, datetime.now(timezone.utc))
|
|
68
|
+
|
|
69
|
+
decision = gate.evaluate(inp)
|
|
70
|
+
if decision.allowed and gate.commit(decision, inp):
|
|
71
|
+
send(candidate, decision.surfaces)
|
|
72
|
+
else:
|
|
73
|
+
print(decision.rejected_by, decision.reason)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`evaluate` reads; `commit` is the atomic increment right before you send, and it is
|
|
77
|
+
idempotent on `decision.id`. Every decision carries `trace`, one entry per check that ran.
|
|
78
|
+
|
|
79
|
+
## A policy is data
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import json
|
|
83
|
+
from proactive_gate import Gate
|
|
84
|
+
|
|
85
|
+
gate = Gate.from_policy(json.load(open("policy.json")))
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`policy.json` is the same document the TypeScript package and the CLI read
|
|
89
|
+
(`{"specVersion": "1.0.0", "checks": [{"id": "consent"}, {"preset": "usTcpa"}, ...]}`).
|
|
90
|
+
Unknown check ids and presets raise `ValueError` naming the known ones; `shadow: true`
|
|
91
|
+
keeps a check observing without letting it decide.
|
|
92
|
+
|
|
93
|
+
## Async
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from proactive_gate import AsyncGate, RedisStore
|
|
97
|
+
import redis.asyncio as redis
|
|
98
|
+
|
|
99
|
+
gate = AsyncGate.from_policy(policy, RedisStore(redis.from_url("redis://localhost")))
|
|
100
|
+
decision = await gate.evaluate(inp)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`Gate` and `AsyncGate` share one decision loop (`Evaluation`): a check names the store keys
|
|
104
|
+
it needs and decides purely from their values, so the two gates cannot drift. `decide()`
|
|
105
|
+
runs that loop with values you supply, for tests with no store at all.
|
|
106
|
+
|
|
107
|
+
## Stores
|
|
108
|
+
|
|
109
|
+
`MemoryStore` (in-process), `SqliteStore` (standard library, one file per host),
|
|
110
|
+
`AsyncMemoryStore`, and `RedisStore` over `redis.asyncio` (INCR, then EXPIRE on the first
|
|
111
|
+
increment). Any object with `get`, `set`, `incr` and `delete` works.
|
|
112
|
+
|
|
113
|
+
## Presets
|
|
114
|
+
|
|
115
|
+
`proactive_gate.presets` holds the same fourteen presets as the TypeScript package, each with
|
|
116
|
+
its source URLs and a note on what it leaves out: LINE, WeChat, WeCom, Kakao, Korea's Network
|
|
117
|
+
Act, Japan's anti-spam law, China's minor mode, US TCPA, EU ePrivacy, Telegram and Slack.
|
|
118
|
+
They are reviewable defaults, not legal advice.
|
|
119
|
+
|
|
120
|
+
## Conformance
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
pytest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
runs every fixture under `../spec/fixtures` through both gates. A fixture the Python package
|
|
127
|
+
cannot yet satisfy is listed in `spec/skip/python.txt` with a reason; the list is empty.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
proactive_gate/__init__.py,sha256=eXdIfKGIUHR0JvfkzdZwhUAfAkrXE5Q8wCLlB1uAHTE,1143
|
|
2
|
+
proactive_gate/checks.py,sha256=ZP19Qs5ggtCBc8FfeajyCgBRKWqrme_Zf5oFOwYTHKw,18385
|
|
3
|
+
proactive_gate/clock.py,sha256=4PlvwoQ-hAY_O6Q-fW8D2153pT18CLbPdWZNWj4v3UY,1280
|
|
4
|
+
proactive_gate/conformance.py,sha256=SFIh1ijv0wiS5rNEbebaVCPqX_pkQOCNEzHEGn6vY6w,6051
|
|
5
|
+
proactive_gate/gate.py,sha256=N0QfoV5zeGfYCmG6IvmclC8xyovZVOFUtZqbUnnUoJ0,15842
|
|
6
|
+
proactive_gate/policy.py,sha256=c54i53_zcXSo3G9Thy8YKPQz2bxu-fp_uv3LCGnQwR8,4477
|
|
7
|
+
proactive_gate/presets.py,sha256=5mm16_y61ydYpjMspUsvh8Tr-x59rnGJMyY0YgFXs8I,7121
|
|
8
|
+
proactive_gate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
proactive_gate/stores.py,sha256=KmVSxdVi4tiCFPRyZGtvnIY_gJVpNZtSMYtqjKykKPQ,6150
|
|
10
|
+
proactive_gate/types.py,sha256=pgwv7ZSQyrnKu-UdLvEQ_YBUg5nBUMcMzY2shC7FO_c,6615
|
|
11
|
+
proactive_gate-0.2.1.dist-info/METADATA,sha256=XCvHsd27rnZfunJWZxDf4MIfPnX1oW2y5FXSduxp2Go,5191
|
|
12
|
+
proactive_gate-0.2.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
13
|
+
proactive_gate-0.2.1.dist-info/RECORD,,
|