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.
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/PKG-INFO +3 -2
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/README.md +2 -1
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/pyproject.toml +1 -1
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/__init__.py +1 -1
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/explain.py +12 -3
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/stores.py +20 -1
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/test_explain.py +21 -3
- proactive_gate-0.8.0/tests/test_redis.py +154 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/.gitignore +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/checks.py +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/clock.py +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/conformance.py +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/gate.py +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/policy.py +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/presets.py +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/py.typed +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/src/proactive_gate/types.py +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/conftest.py +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/test_clock.py +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/test_conformance.py +0 -0
- {proactive_gate-0.7.2 → proactive_gate-0.8.0}/tests/test_stores.py +0 -0
- {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.
|
|
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,
|
|
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,
|
|
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
|
+
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"
|
|
@@ -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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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"
|
|
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
|
|
File without changes
|