proactive-gate 0.7.2__tar.gz → 0.8.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.
Files changed (22) hide show
  1. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/PKG-INFO +3 -2
  2. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/README.md +2 -1
  3. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/pyproject.toml +1 -1
  4. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/__init__.py +1 -1
  5. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/explain.py +12 -3
  6. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/stores.py +20 -1
  7. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/test_explain.py +21 -3
  8. proactive_gate-0.8.0/tests/test_redis.py +154 -0
  9. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/.gitignore +0 -0
  10. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/checks.py +0 -0
  11. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/clock.py +0 -0
  12. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/conformance.py +0 -0
  13. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/gate.py +0 -0
  14. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/policy.py +0 -0
  15. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/presets.py +0 -0
  16. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/py.typed +0 -0
  17. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/types.py +0 -0
  18. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/conftest.py +0 -0
  19. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/test_clock.py +0 -0
  20. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/test_conformance.py +0 -0
  21. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/test_stores.py +0 -0
  22. {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: proactive-gate
3
- Version: 0.7.2
3
+ Version: 0.8.0
4
4
  Summary: Decide whether a proactive assistant may speak now: ordered checks, budgets, quiet hours, JSON policies, shared conformance fixtures with the TypeScript package.
5
5
  Project-URL: Homepage, https://bubblegunn.github.io/proactive-gate/
6
6
  Project-URL: Documentation, https://bubblegunn.github.io/proactive-gate/start/
@@ -128,7 +128,8 @@ runs that loop with values you supply, for tests with no store at all.
128
128
  [@LouisDeconinck](https://github.com/LouisDeconinck) in
129
129
  [#32](https://github.com/Bubblegunn/proactive-gate/pull/32); the same file and expiry rules
130
130
  as `SqliteStore`, without blocking the loop), and `RedisStore` over `redis.asyncio`
131
- (INCR, then EXPIRE on the first increment). Any object with `get`, `set`, `incr` and
131
+ (INCR, and EXPIRE on the first increment, run as one Lua script through the client's
132
+ `eval`, so a failure between the two cannot leave a counter with no TTL). Any object with `get`, `set`, `incr` and
132
133
  `delete` works. `SqliteStore` removes an expired row when a read touches it and clears
133
134
  every already-expired row on each `set` or `incr`, so a key nobody reads again still
134
135
  disappears.
@@ -88,7 +88,8 @@ runs that loop with values you supply, for tests with no store at all.
88
88
  [@LouisDeconinck](https://github.com/LouisDeconinck) in
89
89
  [#32](https://github.com/Bubblegunn/proactive-gate/pull/32); the same file and expiry rules
90
90
  as `SqliteStore`, without blocking the loop), and `RedisStore` over `redis.asyncio`
91
- (INCR, then EXPIRE on the first increment). Any object with `get`, `set`, `incr` and
91
+ (INCR, and EXPIRE on the first increment, run as one Lua script through the client's
92
+ `eval`, so a failure between the two cannot leave a counter with no TTL). Any object with `get`, `set`, `incr` and
92
93
  `delete` works. `SqliteStore` removes an expired row when a read touches it and clears
93
94
  every already-expired row on each `set` or `incr`, so a key nobody reads again still
94
95
  disappears.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proactive-gate"
7
- version = "0.7.2"
7
+ version = "0.8.0"
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"
@@ -22,7 +22,7 @@ from .types import (
22
22
  UserState,
23
23
  )
24
24
 
25
- __version__ = "0.7.2"
25
+ __version__ = "0.8.0"
26
26
 
27
27
  __all__ = [
28
28
  "EN", "PRIORITY_RANK", "KNOWN_CHECKS", "AsyncGate", "AsyncMemoryStore", "AsyncSqliteStore", "AsyncStore", "Candidate", "Check",
@@ -501,10 +501,19 @@ def _summarize(decision: Decision, sentences: Mapping[str, SentenceTemplate]) ->
501
501
  clause = _t(sentences, template, facts)
502
502
  # For a deferral the hold ends at retryAt; when the clause already names the
503
503
  # instant (snooze's reason is "snoozed until X"), naming it twice reads worse.
504
+ #
505
+ # The summary may headline an instant ONLY when the decision will actually be
506
+ # reconsidered at it. quietHours can only reject, and its facts carry the
507
+ # window end as holdUntil, so this used to render a reject as "held until
508
+ # 08:00" -- telling the reader the message goes out at 08:00 when nothing
509
+ # would ever send it. The window is not lost: the clause still names it.
504
510
  clause_says_when = "until" in facts
505
- until = facts.get("holdUntil")
506
- if until is None and not clause_says_when and decision.deferred_by and decision.retry_at is not None:
507
- until = iso_z(decision.retry_at)
511
+ will_be_reconsidered = entry.outcome == "defer"
512
+ until = None
513
+ if will_be_reconsidered:
514
+ until = facts.get("holdUntil")
515
+ if until is None and not clause_says_when and decision.retry_at is not None:
516
+ until = iso_z(decision.retry_at)
508
517
  held_facts: Facts = {"clause": clause}
509
518
  if until is not None:
510
519
  held_facts["until"] = until
@@ -232,8 +232,24 @@ class AsyncSqliteStore:
232
232
  await self._conn.close()
233
233
 
234
234
 
235
+ # INCR, and EXPIRE when the increment created the key. Redis runs a script without interleaving
236
+ # other commands, so the TTL is attached in the same step that creates the counter. The
237
+ # TypeScript RedisStore runs the same script.
238
+ _INCR_SCRIPT = (
239
+ "local n = redis.call('INCR', KEYS[1]) "
240
+ "if n == 1 and tonumber(ARGV[1]) > 0 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end "
241
+ "return n"
242
+ )
243
+
244
+
235
245
  class RedisStore:
236
- """Wraps a ``redis.asyncio`` client. INCR, then EXPIRE on the first increment so a key never lives forever."""
246
+ """Wraps a ``redis.asyncio`` client.
247
+
248
+ ``incr`` runs INCR and, on the first increment, EXPIRE as one server-side script, so a counter
249
+ can never be left without a TTL. A client object with no ``eval`` method falls back to INCR then
250
+ a separate EXPIRE; a failure between those two round trips would leave a counter that never
251
+ expires.
252
+ """
237
253
 
238
254
  def __init__(self, client: Any) -> None:
239
255
  self.client = client
@@ -251,6 +267,9 @@ class RedisStore:
251
267
  await self.client.set(key, value)
252
268
 
253
269
  async def incr(self, key: str, ttl_seconds: int | None = None) -> int:
270
+ evaluate = getattr(self.client, "eval", None)
271
+ if evaluate is not None:
272
+ return int(await evaluate(_INCR_SCRIPT, 1, key, ttl_seconds or 0))
254
273
  value = int(await self.client.incr(key))
255
274
  if value == 1 and ttl_seconds:
256
275
  await self.client.expire(key, ttl_seconds)
@@ -63,7 +63,21 @@ def test_plain_sentence_and_machine_reason_are_both_on_hand() -> None:
63
63
  d = evaluate([checks.QuietHours()], now=NIGHT)
64
64
  e = explain(d)
65
65
  assert d.reason == "quiet hours 22:00 to 08:00 Europe/Istanbul; priority normal is below the floor (critical)"
66
- assert e.summary == "Held until 08:00 because the user's quiet hours run 22:00 to 08:00 Europe/Istanbul and normal priority is below the critical floor needed to override them."
66
+ # Not "Held until 08:00": QuietHours only rejects, and nothing retries a
67
+ # reject. The expectation was changed because it pinned a defect, not
68
+ # because a test failed. See test_a_reject_never_names_an_instant below.
69
+ assert e.summary == "Held because the user's quiet hours run 22:00 to 08:00 Europe/Istanbul and normal priority is below the critical floor needed to override them."
70
+
71
+
72
+ def test_a_reject_never_names_an_instant() -> None:
73
+ """The summary may headline a time only when the decision is reconsidered at it."""
74
+ d = evaluate([checks.QuietHours()], now=NIGHT)
75
+ assert d.allowed is False
76
+ assert d.retry_at is None, "the fixture must be a reject, or this test proves nothing"
77
+ assert d.deferred_by is None
78
+ summary = explain(d).summary
79
+ assert "until" not in summary, f"a reject must not name an instant: {summary}"
80
+ assert "quiet hours run 22:00 to 08:00" in summary
67
81
 
68
82
 
69
83
  def test_not_stopped_every_check_gets_a_sentence() -> None:
@@ -353,9 +367,13 @@ def test_language_is_a_parameter() -> None:
353
367
  assert explain(d).summary == explain(d, language="en").summary
354
368
  with pytest.raises(ValueError, match='no sentence catalog for language "tr"'):
355
369
  explain(d, language="tr")
356
- tr = {"summary.held": lambda f: f"{f['until']} kadar bekletildi, çünkü {f['clause']}"}
370
+ # `until` is optional: the summary carries it only when the decision will be
371
+ # reconsidered at it, so a caller catalog must handle its absence the way the
372
+ # shipped English one does. This template used to index it directly and only
373
+ # worked because the fixture happened to be a reject that wrongly named a time.
374
+ tr = {"summary.held": lambda f: f"{f['until'] + ' kadar ' if f.get('until') else ''}bekletildi, çünkü {f['clause']}"}
357
375
  e = explain(d, language="tr", catalogs={"tr": tr})
358
- assert e.summary.startswith("08:00 kadar bekletildi, çünkü")
376
+ assert e.summary.startswith("Bekletildi, çünkü")
359
377
  assert len(EN) > 0
360
378
 
361
379
 
@@ -0,0 +1,154 @@
1
+ """RedisStore: TTL attachment, with a fake client always and against a real server when one is given.
2
+
3
+ The real-server tests read PROACTIVE_GATE_REDIS_URL and skip with that reason when it is unset,
4
+ so ``pytest`` still passes with no Redis. They use their own key prefix and delete only their own
5
+ keys; nothing is flushed.
6
+ """
7
+
8
+ import asyncio
9
+ import os
10
+ import time
11
+ from typing import Any
12
+
13
+ import pytest
14
+
15
+ from proactive_gate import RedisStore
16
+
17
+ URL = os.environ.get("PROACTIVE_GATE_REDIS_URL")
18
+ needs_server = pytest.mark.skipif(URL is None, reason="PROACTIVE_GATE_REDIS_URL is not set")
19
+
20
+
21
+ class FaultyClient:
22
+ """In-memory stand-in. ``eval`` runs the script's two steps in one call, as Redis does for a
23
+ script; the separate ``expire`` fails, standing in for a connection dropped between two round
24
+ trips."""
25
+
26
+ def __init__(self, with_eval: bool) -> None:
27
+ self.data: dict[str, tuple[int, int]] = {}
28
+ self.calls: list[str] = []
29
+ self.with_eval = with_eval
30
+
31
+ def _incr(self, key: str) -> int:
32
+ value, ttl = self.data.get(key, (0, -1))
33
+ self.data[key] = (value + 1, ttl)
34
+ return value + 1
35
+
36
+ async def incr(self, key: str) -> int:
37
+ self.calls.append("incr")
38
+ return self._incr(key)
39
+
40
+ async def expire(self, key: str, seconds: int) -> None:
41
+ self.calls.append("expire")
42
+ raise ConnectionError("connection lost before EXPIRE")
43
+
44
+ def __getattr__(self, name: str) -> Any:
45
+ if name == "eval" and self.with_eval:
46
+ async def eval_(script: str, numkeys: int, key: str, ttl: int) -> int:
47
+ self.calls.append("eval")
48
+ value = self._incr(key)
49
+ if value == 1 and int(ttl) > 0:
50
+ self.data[key] = (value, int(ttl))
51
+ return value
52
+ return eval_
53
+ raise AttributeError(name)
54
+
55
+
56
+ def test_failure_between_incr_and_expire_cannot_leave_a_counter_without_ttl() -> None:
57
+ client = FaultyClient(with_eval=True)
58
+
59
+ async def run() -> None:
60
+ try:
61
+ await RedisStore(client).incr("budget:u1", 60)
62
+ except ConnectionError:
63
+ pass
64
+
65
+ asyncio.run(run())
66
+ entry = client.data.get("budget:u1")
67
+ assert entry is None or entry[1] > 0, f"counter left with no TTL after calls {client.calls}"
68
+ assert client.calls == ["eval"]
69
+
70
+
71
+ def test_client_without_eval_still_works() -> None:
72
+ class Plain:
73
+ def __init__(self) -> None:
74
+ self.n = 0
75
+
76
+ async def incr(self, key: str) -> int:
77
+ self.n += 1
78
+ return self.n
79
+
80
+ async def expire(self, key: str, seconds: int) -> None:
81
+ return None
82
+
83
+ async def run() -> list[int]:
84
+ store = RedisStore(Plain())
85
+ return [await store.incr("k", 60), await store.incr("k", 60)]
86
+
87
+ assert asyncio.run(run()) == [1, 2]
88
+
89
+
90
+ async def _client() -> Any:
91
+ from redis.asyncio import Redis
92
+
93
+ return Redis.from_url(URL or "")
94
+
95
+
96
+ @needs_server
97
+ def test_real_server_get_set_incr_delete() -> None:
98
+ prefix = f"pg-py:{os.getpid()}:{time.time_ns()}:"
99
+
100
+ async def run() -> None:
101
+ client = await _client()
102
+ store = RedisStore(client)
103
+ try:
104
+ assert await store.get(prefix + "a") is None
105
+ await store.set(prefix + "a", "1")
106
+ assert await store.get(prefix + "a") == "1"
107
+ await store.set(prefix + "b", "v", 60)
108
+ assert 0 < await client.ttl(prefix + "b") <= 60
109
+ assert await store.incr(prefix + "n", 60) == 1
110
+ assert await store.incr(prefix + "n", 60) == 2
111
+ assert 0 < await client.ttl(prefix + "n") <= 60
112
+ assert await store.incr(prefix + "plain") == 1
113
+ assert await client.ttl(prefix + "plain") == -1
114
+ results = await asyncio.gather(*(store.incr(prefix + "c") for _ in range(100)))
115
+ assert sorted(results) == list(range(1, 101))
116
+ await store.delete(prefix + "a")
117
+ assert await store.get(prefix + "a") is None
118
+ finally:
119
+ keys = [key async for key in client.scan_iter(match=prefix + "*")]
120
+ if keys:
121
+ await client.delete(*keys)
122
+ await client.aclose()
123
+
124
+ asyncio.run(run())
125
+
126
+
127
+ @needs_server
128
+ def test_real_server_expire_failing_after_incr_leaves_no_counter_without_ttl() -> None:
129
+ key = f"pg-py-atomic:{os.getpid()}:{time.time_ns()}"
130
+
131
+ class FailingExpire:
132
+ def __init__(self, inner: Any) -> None:
133
+ self.inner = inner
134
+
135
+ async def expire(self, key: str, seconds: int) -> None:
136
+ raise ConnectionError("connection lost before EXPIRE")
137
+
138
+ def __getattr__(self, name: str) -> Any:
139
+ return getattr(self.inner, name)
140
+
141
+ async def run() -> int:
142
+ client = await _client()
143
+ try:
144
+ try:
145
+ await RedisStore(FailingExpire(client)).incr(key, 60)
146
+ except ConnectionError:
147
+ pass
148
+ return int(await client.ttl(key))
149
+ finally:
150
+ await client.delete(key)
151
+ await client.aclose()
152
+
153
+ ttl = asyncio.run(run())
154
+ assert ttl == -2 or ttl > 0, f"TTL is {ttl}; -1 means the counter never expires"