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.
@@ -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"]
File without changes
@@ -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"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any