proactive-gate 0.8.0__tar.gz → 0.8.2__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.8.0 → proactive_gate-0.8.2}/PKG-INFO +1 -1
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/pyproject.toml +1 -1
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/__init__.py +1 -1
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/explain.py +3 -1
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/tests/test_explain.py +31 -0
- proactive_gate-0.8.2/tests/test_requirements_ci.py +54 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/.gitignore +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/README.md +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/checks.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/clock.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/conformance.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/gate.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/policy.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/presets.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/py.typed +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/stores.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/src/proactive_gate/types.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/tests/conftest.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/tests/test_clock.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/tests/test_conformance.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/tests/test_redis.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/tests/test_stores.py +0 -0
- {proactive_gate-0.8.0 → proactive_gate-0.8.2}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: proactive-gate
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
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/
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proactive-gate"
|
|
7
|
-
version = "0.8.
|
|
7
|
+
version = "0.8.2"
|
|
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"
|
|
@@ -295,7 +295,9 @@ PARSERS: dict[str, dict[str, Parser | Callable[[str], Facts]]] = {
|
|
|
295
295
|
"stop": _match(r"trust ramp: day (\d+) of (\S+), priority (\w+) is below (\w+)", ("day", "days", "priority", "floor")),
|
|
296
296
|
"skip": _fixed("no createdAt on the user; ramp cannot be evaluated"),
|
|
297
297
|
},
|
|
298
|
-
|
|
298
|
+
# The instant is an ISO timestamp, so \S+: with (.+) the type and the
|
|
299
|
+
# instant could both absorb a crafted reason and the match went quadratic.
|
|
300
|
+
"dismissalCooldown": {"stop": _match(r'(\d+) dismissals of "(.+)" in (\S+) days; silent until (\S+)', ("count", "type", "within", "until"))},
|
|
299
301
|
"adaptiveTiming": {
|
|
300
302
|
"adjust": _first(
|
|
301
303
|
_match(r"deliver at (\S+); surfaces (.+)", ("at", "surfaces")),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import re
|
|
5
|
+
import time
|
|
5
6
|
from datetime import datetime, timezone
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
@@ -322,6 +323,36 @@ def test_shadowed_stop_is_what_would_have_happened() -> None:
|
|
|
322
323
|
assert "quietHours would have stopped it but ran in shadow mode" in e.summary
|
|
323
324
|
|
|
324
325
|
|
|
326
|
+
def test_dismissal_cooldown_type_with_the_reasons_own_separators() -> None:
|
|
327
|
+
store = MemoryStore()
|
|
328
|
+
gate = Gate([checks.DismissalCooldown()], store=store)
|
|
329
|
+
u = user()
|
|
330
|
+
kind = 'a" in 9 days; silent until b'
|
|
331
|
+
for day in (1, 2, 3):
|
|
332
|
+
gate.record(u, Candidate(id=f"c{day}", type=kind), "dismissed", datetime(2026, 9, day, 10, 0, tzinfo=UTC))
|
|
333
|
+
d = gate.evaluate(EvaluateInput(u, candidate(type=kind), datetime(2026, 9, 5, 10, 0, tzinfo=UTC)))
|
|
334
|
+
assert explain(d).summary == f'Held because the user has dismissed "{kind}" messages 3 times in 30 days, so this type stays silent until 2026-09-10T10:00:00.000Z.'
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_dismissal_cooldown_reason_built_to_backtrack_reads_in_linear_time() -> None:
|
|
338
|
+
# The pattern CodeQL flagged as js/polynomial-redos in the TypeScript twin;
|
|
339
|
+
# Python's re backtracks the same way on "silent until (.+)".
|
|
340
|
+
reason = '9 dismissals of "a" in ! days; silent until ' + 'a" in ! days; silent until a' * 20_000 + "\n"
|
|
341
|
+
d = Decision(
|
|
342
|
+
id="u1:c1:2026-09-04T09:00:00.000Z#1",
|
|
343
|
+
allowed=False,
|
|
344
|
+
user_id="u1",
|
|
345
|
+
candidate_id="c1",
|
|
346
|
+
surfaces=(),
|
|
347
|
+
trace=(TraceEntry(id="dismissalCooldown", outcome="reject", ms=0.0, reason=reason),),
|
|
348
|
+
evaluated_at=NOON,
|
|
349
|
+
)
|
|
350
|
+
started = time.perf_counter()
|
|
351
|
+
explain(d)
|
|
352
|
+
elapsed = time.perf_counter() - started
|
|
353
|
+
assert elapsed < 1.0, f"explain took {elapsed:.2f} s on a 20,000-repeat reason"
|
|
354
|
+
|
|
355
|
+
|
|
325
356
|
def test_shadowed_pass_does_not_claim_it_would_have_stopped() -> None:
|
|
326
357
|
d = Decision(
|
|
327
358
|
id="u1:c1:2026-09-04T09:00:00.000Z#1",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""CI installs ``requirements-ci.txt`` with ``--require-hashes``; it has to cover what it is compiled from.
|
|
2
|
+
|
|
3
|
+
The file is compiled (command in its header) from the dev extra in pyproject.toml and
|
|
4
|
+
from requirements-ci.in, so pip can install the test, build and check tools pinned by
|
|
5
|
+
hash, and the package itself goes in with ``--no-deps``. A requirement added to either
|
|
6
|
+
input without recompiling would otherwise surface only as an ImportError in CI, and a
|
|
7
|
+
pin outside its range would mean CI tests against a version the inputs exclude.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import tomllib
|
|
13
|
+
|
|
14
|
+
from packaging.requirements import Requirement
|
|
15
|
+
from packaging.utils import canonicalize_name
|
|
16
|
+
|
|
17
|
+
from conftest import ROOT
|
|
18
|
+
|
|
19
|
+
PYTHON = ROOT / "python"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _inputs() -> list[Requirement]:
|
|
23
|
+
with (PYTHON / "pyproject.toml").open("rb") as handle:
|
|
24
|
+
dev = tomllib.load(handle)["project"]["optional-dependencies"]["dev"]
|
|
25
|
+
lines = (PYTHON / "requirements-ci.in").read_text(encoding="utf-8").splitlines()
|
|
26
|
+
tools = [line for line in (raw.split("#", 1)[0].strip() for raw in lines) if line]
|
|
27
|
+
return [Requirement(spec) for spec in [*dev, *tools]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _pins() -> dict[str, tuple[str, int]]:
|
|
31
|
+
text = (PYTHON / "requirements-ci.txt").read_text(encoding="utf-8")
|
|
32
|
+
pins: dict[str, tuple[str, int]] = {}
|
|
33
|
+
# A requirement starts at column 0; its hashes follow on indented continuation lines.
|
|
34
|
+
for block in re.split(r"\n(?=[a-z0-9])", text):
|
|
35
|
+
m = re.match(r"([a-z0-9][a-z0-9._-]*)==(\S+)", block)
|
|
36
|
+
if m:
|
|
37
|
+
pins[canonicalize_name(m.group(1))] = (m.group(2), len(re.findall(r"--hash=sha256:[0-9a-f]{64}", block)))
|
|
38
|
+
return pins
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_every_input_is_pinned_inside_its_range() -> None:
|
|
42
|
+
pins = _pins()
|
|
43
|
+
wrong = []
|
|
44
|
+
for req in _inputs():
|
|
45
|
+
pinned = pins.get(canonicalize_name(req.name))
|
|
46
|
+
if pinned is None or not req.specifier.contains(pinned[0], prereleases=True):
|
|
47
|
+
wrong.append(f"{req} -> {pinned[0] if pinned else 'not pinned'}")
|
|
48
|
+
assert wrong == []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_every_pin_carries_a_hash() -> None:
|
|
52
|
+
pins = _pins()
|
|
53
|
+
assert len(pins) > 0
|
|
54
|
+
assert [name for name, (_, hashes) in pins.items() if hashes == 0] == []
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|