proactive-gate 0.2.1__tar.gz → 0.2.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.2.1 → proactive_gate-0.2.2}/PKG-INFO +11 -9
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/README.md +10 -8
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/pyproject.toml +1 -1
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/src/proactive_gate/checks.py +44 -4
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/src/proactive_gate/clock.py +17 -1
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/src/proactive_gate/types.py +38 -3
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/.gitignore +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/src/proactive_gate/__init__.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/src/proactive_gate/conformance.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/src/proactive_gate/gate.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/src/proactive_gate/policy.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/src/proactive_gate/presets.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/src/proactive_gate/py.typed +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/src/proactive_gate/stores.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/tests/conftest.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/tests/test_clock.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/tests/test_conformance.py +0 -0
- {proactive_gate-0.2.1 → proactive_gate-0.2.2}/tests/test_stores.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: proactive-gate
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.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: Repository, https://github.com/Bubblegunn/proactive-gate
|
|
@@ -40,17 +40,19 @@ This is the Python sibling of the TypeScript package. Both implement the same be
|
|
|
40
40
|
contract in [`spec/`](https://github.com/Bubblegunn/proactive-gate/tree/main/spec) and run
|
|
41
41
|
the same fixtures, so a policy written for one behaves the same in the other.
|
|
42
42
|
|
|
43
|
-
Until the first PyPI release, install from the repository:
|
|
44
|
-
|
|
45
43
|
```
|
|
46
|
-
|
|
47
|
-
pip install ./proactive-gate/python
|
|
44
|
+
pip install proactive-gate
|
|
48
45
|
```
|
|
49
46
|
|
|
50
|
-
or,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
Python 3.11 or newer, no runtime dependencies. Add `[redis]` (`pip install "proactive-gate[redis]"`)
|
|
48
|
+
for the Redis store.
|
|
49
|
+
|
|
50
|
+
To run an unreleased state, install from the repository instead:
|
|
51
|
+
`pip install "proactive-gate @ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`.
|
|
52
|
+
|
|
53
|
+
The published 0.2.1 was uploaded from a local build with a token, so unlike the npm package it
|
|
54
|
+
carries no build provenance. Trusted publishing is not configured yet; when it is, releases will
|
|
55
|
+
come from the workflow.
|
|
54
56
|
|
|
55
57
|
## Use
|
|
56
58
|
|
|
@@ -9,17 +9,19 @@ This is the Python sibling of the TypeScript package. Both implement the same be
|
|
|
9
9
|
contract in [`spec/`](https://github.com/Bubblegunn/proactive-gate/tree/main/spec) and run
|
|
10
10
|
the same fixtures, so a policy written for one behaves the same in the other.
|
|
11
11
|
|
|
12
|
-
Until the first PyPI release, install from the repository:
|
|
13
|
-
|
|
14
12
|
```
|
|
15
|
-
|
|
16
|
-
pip install ./proactive-gate/python
|
|
13
|
+
pip install proactive-gate
|
|
17
14
|
```
|
|
18
15
|
|
|
19
|
-
or,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
Python 3.11 or newer, no runtime dependencies. Add `[redis]` (`pip install "proactive-gate[redis]"`)
|
|
17
|
+
for the Redis store.
|
|
18
|
+
|
|
19
|
+
To run an unreleased state, install from the repository instead:
|
|
20
|
+
`pip install "proactive-gate @ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`.
|
|
21
|
+
|
|
22
|
+
The published 0.2.1 was uploaded from a local build with a token, so unlike the npm package it
|
|
23
|
+
carries no build provenance. Trusted publishing is not configured yet; when it is, releases will
|
|
24
|
+
come from the workflow.
|
|
23
25
|
|
|
24
26
|
## Use
|
|
25
27
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proactive-gate"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.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"
|
|
@@ -10,7 +10,7 @@ from collections.abc import Callable, Mapping, Sequence
|
|
|
10
10
|
from datetime import datetime, timedelta
|
|
11
11
|
from typing import Protocol, runtime_checkable
|
|
12
12
|
|
|
13
|
-
from .clock import DAY_SECONDS, in_window, iso_week_key, local_clock, local_day, parse_hhmm
|
|
13
|
+
from .clock import DAY_SECONDS, day_before, in_window, iso_week_key, local_clock, local_day, parse_hhmm, weekday_of
|
|
14
14
|
from .types import (
|
|
15
15
|
PASS,
|
|
16
16
|
ConsumePlan,
|
|
@@ -18,6 +18,8 @@ from .types import (
|
|
|
18
18
|
NearLimit,
|
|
19
19
|
Outcome,
|
|
20
20
|
Priority,
|
|
21
|
+
QuietSchedule,
|
|
22
|
+
QuietWindow,
|
|
21
23
|
at_least,
|
|
22
24
|
defer,
|
|
23
25
|
epoch_ms,
|
|
@@ -150,6 +152,39 @@ class Intensity(BaseCheck):
|
|
|
150
152
|
return reject(f'priority {ctx.priority} is below the "{level}" intensity floor ({floor})')
|
|
151
153
|
|
|
152
154
|
|
|
155
|
+
def window_for(quiet: QuietWindow | QuietSchedule, day: str) -> QuietWindow | None:
|
|
156
|
+
"""The window in force on one local date: a date beats a weekday beats the default."""
|
|
157
|
+
if isinstance(quiet, QuietWindow):
|
|
158
|
+
return quiet
|
|
159
|
+
if day in quiet.dates:
|
|
160
|
+
return quiet.dates[day]
|
|
161
|
+
weekday = weekday_of(day)
|
|
162
|
+
if weekday in quiet.days:
|
|
163
|
+
return quiet.days[weekday]
|
|
164
|
+
return quiet.default
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def quiet_at(quiet: QuietWindow | QuietSchedule, day: str, minutes: int) -> tuple[QuietWindow, str] | None:
|
|
168
|
+
"""The window silencing ``minutes`` on ``day``, and the day it opened on.
|
|
169
|
+
|
|
170
|
+
A window that crosses midnight belongs to the day it opens on, so a time can be
|
|
171
|
+
quiet because of yesterday. With one window every day this reduces exactly to
|
|
172
|
+
``in_window``, which is why the single-window form behaves as it did.
|
|
173
|
+
"""
|
|
174
|
+
today = window_for(quiet, day)
|
|
175
|
+
if today is not None:
|
|
176
|
+
start, end = parse_hhmm(today.start), parse_hhmm(today.end)
|
|
177
|
+
if start != end and (start <= minutes < end if start < end else minutes >= start):
|
|
178
|
+
return today, day
|
|
179
|
+
previous = day_before(day)
|
|
180
|
+
yesterday = window_for(quiet, previous)
|
|
181
|
+
if yesterday is not None:
|
|
182
|
+
start, end = parse_hhmm(yesterday.start), parse_hhmm(yesterday.end)
|
|
183
|
+
if start > end and minutes < end:
|
|
184
|
+
return yesterday, previous
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
153
188
|
class QuietHours(BaseCheck):
|
|
154
189
|
id = "quietHours"
|
|
155
190
|
|
|
@@ -162,12 +197,17 @@ class QuietHours(BaseCheck):
|
|
|
162
197
|
return PASS
|
|
163
198
|
if not ctx.user.timezone:
|
|
164
199
|
return skip("quiet hours set but no timezone on the user; cannot evaluate")
|
|
165
|
-
minutes,
|
|
166
|
-
|
|
200
|
+
minutes, day = local_clock(ctx.now, ctx.user.timezone)
|
|
201
|
+
hit = quiet_at(qh, day, minutes)
|
|
202
|
+
if hit is None:
|
|
167
203
|
return PASS
|
|
168
204
|
if at_least(ctx.priority, self.floor):
|
|
169
205
|
return PASS
|
|
170
|
-
|
|
206
|
+
window, from_day = hit
|
|
207
|
+
# Name the day the window opened on when it was not today: the reason is
|
|
208
|
+
# yesterday's setting, and a reader checking today's would not find it.
|
|
209
|
+
whose = "" if from_day == day else f" ({weekday_of(from_day)} {from_day})"
|
|
210
|
+
return reject(f"quiet hours {window.start} to {window.end}{whose} {ctx.user.timezone}; priority {ctx.priority} is below the floor ({self.floor})")
|
|
171
211
|
|
|
172
212
|
|
|
173
213
|
class TrustRamp(BaseCheck):
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Local-time arithmetic with the standard library only."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from datetime import datetime
|
|
4
|
+
from datetime import date, datetime, timedelta
|
|
5
5
|
from zoneinfo import ZoneInfo
|
|
6
6
|
|
|
7
7
|
DAY_SECONDS = 24 * 60 * 60
|
|
@@ -38,3 +38,19 @@ def local_day(now: datetime, tz: str | None) -> str:
|
|
|
38
38
|
def iso_week_key(day: str) -> str:
|
|
39
39
|
year, week, _ = datetime.strptime(day, "%Y-%m-%d").isocalendar()
|
|
40
40
|
return f"{year}-W{week:02d}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
WEEKDAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def weekday_of(day: str) -> str:
|
|
47
|
+
"""The weekday of a local calendar date, ``"mon"`` to ``"sun"``.
|
|
48
|
+
|
|
49
|
+
Calendar arithmetic on the date ``local_clock`` already resolved, never arithmetic
|
|
50
|
+
on an instant, so a 45-minute offset or a daylight-saving change cannot reach it.
|
|
51
|
+
"""
|
|
52
|
+
return WEEKDAYS[date.fromisoformat(day).weekday()]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def day_before(day: str) -> str:
|
|
56
|
+
return (date.fromisoformat(day) - timedelta(days=1)).isoformat()
|
|
@@ -45,11 +45,46 @@ def epoch_ms(value: datetime) -> int:
|
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
@dataclass(frozen=True, slots=True)
|
|
48
|
-
class
|
|
48
|
+
class QuietWindow:
|
|
49
49
|
start: str
|
|
50
50
|
end: str
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class QuietSchedule:
|
|
55
|
+
"""Quiet hours resolved per day: a date beats a weekday beats the default.
|
|
56
|
+
|
|
57
|
+
``None`` at any level means the day has no quiet hours, which is how a working
|
|
58
|
+
day is carved out of a default. There is no bundled holiday calendar; the dates
|
|
59
|
+
a caller observes are the caller's to supply.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
default: QuietWindow | None = None
|
|
63
|
+
days: Mapping[str, QuietWindow | None] = field(default_factory=dict)
|
|
64
|
+
dates: Mapping[str, QuietWindow | None] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# The single-window form every caller had before schedules existed.
|
|
68
|
+
QuietHours = QuietWindow
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _window(value: Any) -> QuietWindow | None:
|
|
72
|
+
return QuietWindow(str(value["start"]), str(value["end"])) if value else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_quiet_hours(value: Any) -> QuietWindow | QuietSchedule | None:
|
|
76
|
+
"""A window, or a schedule; the two forms are told apart by the ``start`` key."""
|
|
77
|
+
if not value:
|
|
78
|
+
return None
|
|
79
|
+
if "start" in value:
|
|
80
|
+
return _window(value)
|
|
81
|
+
return QuietSchedule(
|
|
82
|
+
default=_window(value.get("default")),
|
|
83
|
+
days={k: _window(v) for k, v in (value.get("days") or {}).items()},
|
|
84
|
+
dates={k: _window(v) for k, v in (value.get("dates") or {}).items()},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
53
88
|
@dataclass(frozen=True, slots=True)
|
|
54
89
|
class UserState:
|
|
55
90
|
id: str
|
|
@@ -60,7 +95,7 @@ class UserState:
|
|
|
60
95
|
muted_types: tuple[str, ...] = ()
|
|
61
96
|
intensity: Intensity | None = None
|
|
62
97
|
timezone: str | None = None
|
|
63
|
-
quiet_hours:
|
|
98
|
+
quiet_hours: QuietWindow | QuietSchedule | None = None
|
|
64
99
|
created_at: datetime | None = None
|
|
65
100
|
surfaces: tuple[str, ...] | None = None
|
|
66
101
|
consents: Mapping[str, bool] = field(default_factory=dict)
|
|
@@ -80,7 +115,7 @@ class UserState:
|
|
|
80
115
|
muted_types=tuple(data.get("mutedTypes") or ()),
|
|
81
116
|
intensity=data.get("intensity"),
|
|
82
117
|
timezone=data.get("timezone"),
|
|
83
|
-
quiet_hours=
|
|
118
|
+
quiet_hours=parse_quiet_hours(qh),
|
|
84
119
|
created_at=to_datetime(data.get("createdAt")),
|
|
85
120
|
surfaces=tuple(data["surfaces"]) if data.get("surfaces") is not None else None,
|
|
86
121
|
consents=dict(data.get("consents") or {}),
|
|
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
|