proactive-gate 0.3.0__tar.gz → 0.3.1__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.
@@ -1,23 +1,29 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: proactive-gate
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
+ Project-URL: Documentation, https://bubblegunn.github.io/proactive-gate/start/
7
+ Project-URL: Playground, https://bubblegunn.github.io/proactive-gate/playground/
6
8
  Project-URL: Repository, https://github.com/Bubblegunn/proactive-gate
7
9
  Project-URL: Issues, https://github.com/Bubblegunn/proactive-gate/issues
8
10
  Project-URL: Specification, https://github.com/Bubblegunn/proactive-gate/tree/main/spec
9
11
  Project-URL: Changelog, https://github.com/Bubblegunn/proactive-gate/blob/main/CHANGELOG.md
10
12
  Author: Efe Genc
11
13
  License-Expression: MIT
12
- Keywords: agents,notifications,policy,proactive,quiet-hours,rate-limit
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
13
15
  Classifier: Development Status :: 4 - Beta
14
16
  Classifier: Framework :: AsyncIO
15
17
  Classifier: Intended Audience :: Developers
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: OS Independent
16
20
  Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3 :: Only
17
22
  Classifier: Programming Language :: Python :: 3.11
18
23
  Classifier: Programming Language :: Python :: 3.12
19
24
  Classifier: Programming Language :: Python :: 3.13
20
25
  Classifier: Topic :: Communications
26
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
27
  Classifier: Topic :: Software Development :: Libraries
22
28
  Classifier: Typing :: Typed
23
29
  Requires-Python: >=3.11
@@ -110,7 +116,9 @@ runs that loop with values you supply, for tests with no store at all.
110
116
 
111
117
  `MemoryStore` (in-process), `SqliteStore` (standard library, one file per host),
112
118
  `AsyncMemoryStore`, and `RedisStore` over `redis.asyncio` (INCR, then EXPIRE on the first
113
- increment). Any object with `get`, `set`, `incr` and `delete` works.
119
+ increment). Any object with `get`, `set`, `incr` and `delete` works. `SqliteStore` removes
120
+ an expired row when a read touches it and clears every already-expired row on each `set` or
121
+ `incr`, so a key nobody reads again still disappears.
114
122
 
115
123
  ## Presets
116
124
 
@@ -79,7 +79,9 @@ runs that loop with values you supply, for tests with no store at all.
79
79
 
80
80
  `MemoryStore` (in-process), `SqliteStore` (standard library, one file per host),
81
81
  `AsyncMemoryStore`, and `RedisStore` over `redis.asyncio` (INCR, then EXPIRE on the first
82
- increment). Any object with `get`, `set`, `incr` and `delete` works.
82
+ increment). Any object with `get`, `set`, `incr` and `delete` works. `SqliteStore` removes
83
+ an expired row when a read touches it and clears every already-expired row on each `set` or
84
+ `incr`, so a key nobody reads again still disappears.
83
85
 
84
86
  ## Presets
85
87
 
@@ -4,13 +4,17 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proactive-gate"
7
- version = "0.3.0"
7
+ version = "0.3.1"
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"
11
11
  license = "MIT"
12
12
  authors = [{ name = "Efe Genc" }]
13
- keywords = ["proactive", "notifications", "agents", "rate-limit", "quiet-hours", "policy"]
13
+ keywords = [
14
+ "proactive", "proactive-ai", "proactive-agents", "ai-agents", "llm", "agents",
15
+ "notifications", "notification-policy", "notification-budget", "quiet-hours",
16
+ "consent", "rate-limit", "policy", "policy-engine", "guardrails", "interruption",
17
+ ]
14
18
  classifiers = [
15
19
  "Development Status :: 4 - Beta",
16
20
  "Intended Audience :: Developers",
@@ -18,8 +22,12 @@ classifiers = [
18
22
  "Programming Language :: Python :: 3.11",
19
23
  "Programming Language :: Python :: 3.12",
20
24
  "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3 :: Only",
21
26
  "Framework :: AsyncIO",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Operating System :: OS Independent",
22
29
  "Topic :: Software Development :: Libraries",
30
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
31
  "Topic :: Communications",
24
32
  "Typing :: Typed",
25
33
  ]
@@ -31,6 +39,8 @@ dev = ["pytest>=8", "mypy>=1.11", "redis>=5"]
31
39
 
32
40
  [project.urls]
33
41
  Homepage = "https://bubblegunn.github.io/proactive-gate/"
42
+ Documentation = "https://bubblegunn.github.io/proactive-gate/start/"
43
+ Playground = "https://bubblegunn.github.io/proactive-gate/playground/"
34
44
  Repository = "https://github.com/Bubblegunn/proactive-gate"
35
45
  Issues = "https://github.com/Bubblegunn/proactive-gate/issues"
36
46
  Specification = "https://github.com/Bubblegunn/proactive-gate/tree/main/spec"
@@ -72,8 +72,15 @@ class SqliteStore:
72
72
  def __init__(self, path: str = ":memory:") -> None:
73
73
  self._conn = sqlite3.connect(path, isolation_level=None, check_same_thread=False)
74
74
  self._conn.execute("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at REAL)")
75
+ # The partial index keeps a sweep proportional to the dead rows instead of a table scan.
76
+ self._conn.execute("CREATE INDEX IF NOT EXISTS kv_expires_at ON kv (expires_at) WHERE expires_at IS NOT NULL")
75
77
  self._lock = threading.Lock()
76
78
 
79
+ def _sweep(self, now: float) -> None:
80
+ """A read prunes only the key it touches; a write first clears every row
81
+ that has already expired, so a key nobody reads again does not live forever."""
82
+ self._conn.execute("DELETE FROM kv WHERE expires_at IS NOT NULL AND expires_at <= ?", (now,))
83
+
77
84
  def _row(self, key: str) -> str | None:
78
85
  row = self._conn.execute("SELECT value, expires_at FROM kv WHERE key = ?", (key,)).fetchone()
79
86
  if row is None:
@@ -89,14 +96,17 @@ class SqliteStore:
89
96
  return self._row(key)
90
97
 
91
98
  def set(self, key: str, value: str, ttl_seconds: int | None = None) -> None:
92
- expires = time.time() + ttl_seconds if ttl_seconds else None
99
+ now = time.time()
100
+ expires = now + ttl_seconds if ttl_seconds else None
93
101
  with self._lock:
102
+ self._sweep(now)
94
103
  self._conn.execute("INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)", (key, value, expires))
95
104
 
96
105
  def incr(self, key: str, ttl_seconds: int | None = None) -> int:
97
106
  with self._lock:
98
107
  self._conn.execute("BEGIN IMMEDIATE")
99
108
  try:
109
+ self._sweep(time.time())
100
110
  current = self._row(key)
101
111
  next_value = int(current or 0) + 1
102
112
  if current is None:
@@ -1,4 +1,6 @@
1
1
  import asyncio
2
+ import sqlite3
3
+ import time
2
4
  from datetime import datetime, timezone
3
5
  from pathlib import Path
4
6
 
@@ -31,6 +33,22 @@ def test_sqlite_store_persists_across_connections(tmp_path: Path) -> None:
31
33
  second.close()
32
34
 
33
35
 
36
+ def test_sqlite_store_removes_expired_rows_on_write(tmp_path: Path) -> None:
37
+ path = str(tmp_path / "gate.sqlite")
38
+ store = SqliteStore(path)
39
+ raw = sqlite3.connect(path, isolation_level=None)
40
+ expired = time.time() - 10
41
+ for i in range(50):
42
+ raw.execute("INSERT INTO kv (key, value, expires_at) VALUES (?, 'v', ?)", (f"stale-{i}", expired))
43
+ raw.execute("INSERT INTO kv (key, value, expires_at) VALUES ('keeper', 'v', ?)", (time.time() + 600,))
44
+ # None of the stale keys were read, so all fifty rows are still physically there.
45
+ assert raw.execute("SELECT COUNT(*) FROM kv").fetchone()[0] == 51
46
+ store.incr("fresh", 60)
47
+ assert {key for (key,) in raw.execute("SELECT key FROM kv")} == {"keeper", "fresh"}
48
+ raw.close()
49
+ store.close()
50
+
51
+
34
52
  def _user() -> UserState:
35
53
  return UserState.from_dict({"id": "u1", "consent": True, "timezone": "Europe/Istanbul", "createdAt": "2026-01-01T00:00:00Z"})
36
54