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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: proactive-gate
3
- Version: 0.2.1
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
- git clone https://github.com/Bubblegunn/proactive-gate
47
- pip install ./proactive-gate/python
44
+ pip install proactive-gate
48
45
  ```
49
46
 
50
- or, without cloning, `pip install "proactive-gate @ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`.
51
- Python 3.11 or newer, no runtime dependencies. Add `[redis]` (`pip install "./proactive-gate/python[redis]"`)
52
- for the Redis store. The PyPI name `proactive-gate` is reserved for this package; the
53
- release workflow publishes there on the next tag.
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
- git clone https://github.com/Bubblegunn/proactive-gate
16
- pip install ./proactive-gate/python
13
+ pip install proactive-gate
17
14
  ```
18
15
 
19
- or, without cloning, `pip install "proactive-gate @ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`.
20
- Python 3.11 or newer, no runtime dependencies. Add `[redis]` (`pip install "./proactive-gate/python[redis]"`)
21
- for the Redis store. The PyPI name `proactive-gate` is reserved for this package; the
22
- release workflow publishes there on the next tag.
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.1"
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, _ = local_clock(ctx.now, ctx.user.timezone)
166
- if not in_window(minutes, parse_hhmm(qh.start), parse_hhmm(qh.end)):
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
- return reject(f"quiet hours {qh.start} to {qh.end} {ctx.user.timezone}; priority {ctx.priority} is below the floor ({self.floor})")
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 QuietHours:
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: QuietHours | None = None
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=QuietHours(str(qh["start"]), str(qh["end"])) if qh else None,
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 {}),