Skip to content

Commit 144f9c9

Browse files
committed
fix: added ttl-based caching, misc fixes
1 parent 1f4d036 commit 144f9c9

File tree

1 file changed

+80
-22
lines changed

1 file changed

+80
-22
lines changed

Diff for: aw_notify/main.py

+80-22
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import threading
88
from datetime import datetime, timedelta, timezone
99
from time import sleep
10+
from typing import Optional
1011

1112
import aw_client
1213
import aw_client.queries
@@ -39,14 +40,49 @@
3940
"ignore_case": True,
4041
},
4142
),
43+
(
44+
["Youtube"],
45+
{
46+
"type": "regex",
47+
"regex": r"Youtube|youtube.com",
48+
"ignore_case": True,
49+
},
50+
),
4251
]
4352

4453
time_offset = timedelta(hours=4)
4554

4655
aw = aw_client.ActivityWatchClient("aw-notify", testing=False)
4756

57+
from functools import wraps
58+
59+
60+
def cache_ttl(ttl: timedelta):
61+
"""Decorator that caches the result of a function, with a given time-to-live."""
62+
63+
def wrapper(func):
64+
@wraps(func)
65+
def _cache_ttl(*args, **kwargs):
66+
now = datetime.now(timezone.utc)
67+
if now - _cache_ttl.last_update > ttl:
68+
logger.debug(f"Cache expired for {func.__name__}, updating")
69+
_cache_ttl.last_update = now
70+
_cache_ttl.cache = func(*args, **kwargs)
71+
return _cache_ttl.cache
72+
73+
_cache_ttl.last_update = datetime(1970, 1, 1, tzinfo=timezone.utc)
74+
_cache_ttl.cache = None
75+
return _cache_ttl
76+
77+
return wrapper
78+
79+
80+
@cache_ttl(timedelta(minutes=1))
81+
def get_time() -> dict[str, timedelta]:
82+
"""
83+
Returns a dict with the time spent today for each category.
84+
"""
4885

49-
def get_time(category: str) -> timedelta:
5086
now = datetime.now(timezone.utc)
5187
timeperiods = [
5288
(
@@ -61,18 +97,21 @@ def get_time(category: str) -> timedelta:
6197
bid_window=f"aw-watcher-window_{hostname}",
6298
bid_afk=f"aw-watcher-afk_{hostname}",
6399
classes=CATEGORIES,
64-
filter_classes=[[category]] if category else [],
65100
)
66101
)
67102
query = f"""
68103
{canonicalQuery}
69104
duration = sum_durations(events);
70-
RETURN = {{"events": events, "duration": duration}};
105+
cat_events = sort_by_duration(merge_events_by_keys(events, ["$category"]));
106+
RETURN = {{"events": events, "duration": duration, "cat_events": cat_events}};
71107
"""
72108

73109
res = aw.query(query, timeperiods)[0]
74-
time = timedelta(seconds=res["duration"])
75-
return time
110+
res["cat_events"] += [{"data": {"$category": ["All"]}, "duration": res["duration"]}]
111+
return {
112+
">".join(c["data"]["$category"]): timedelta(seconds=c["duration"])
113+
for c in res["cat_events"]
114+
}
76115

77116

78117
def to_hms(duration: timedelta) -> str:
@@ -130,8 +169,11 @@ class CategoryAlert:
130169
Keeps track of the time spent so far, which alerts to trigger, and which have been triggered.
131170
"""
132171

133-
def __init__(self, category: str, thresholds: list[timedelta]):
172+
def __init__(
173+
self, category: str, thresholds: list[timedelta], label: Optional[str] = None
174+
):
134175
self.category = category
176+
self.label = label or category or "All"
135177
self.thresholds = thresholds
136178
self.max_triggered: timedelta = timedelta()
137179
self.time_spent = timedelta()
@@ -166,8 +208,12 @@ def update(self):
166208
if now > (self.last_check + time_to_threshold):
167209
logger.debug(f"Updating {self.category}")
168210
# print(f"Time to threshold: {time_to_threshold}")
169-
self.last_check = now
170-
self.time_spent = get_time(self.category)
211+
try:
212+
# TODO: move get_time call so that it is cached better
213+
self.time_spent = get_time()[self.category]
214+
self.last_check = now
215+
except Exception as e:
216+
logger.error(f"Error getting time for {self.category}: {e}")
171217
else:
172218
pass
173219
# logger.debug("Not updating, too soon")
@@ -180,12 +226,12 @@ def check(self):
180226
self.max_triggered = thres
181227
notify(
182228
"Time spent",
183-
f"{self.category or 'All'}: {to_hms(thres)} reached! ({to_hms(self.time_spent)})",
229+
f"{self.label}: {to_hms(thres)} reached! ({to_hms(self.time_spent)})",
184230
)
185231
break
186232

187233
def status(self) -> str:
188-
return f"""{self.category or 'All'}: {to_hms(self.time_spent)}"""
234+
return f"""{self.label}: {to_hms(self.time_spent)}"""
189235
# (time to thres: {to_hms(self.time_to_next_threshold)})
190236
# triggered: {self.max_triggered}"""
191237

@@ -199,7 +245,12 @@ def test_category_alert():
199245
@click.group()
200246
@click.option("-v", "--verbose", is_flag=True, help="Enables verbose mode.")
201247
def main(verbose: bool):
202-
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO)
248+
logging.basicConfig(
249+
level=logging.DEBUG if verbose else logging.INFO,
250+
format="%(asctime)s [%(levelname)5s] %(message)s"
251+
+ (" (%(name)s.%(funcName)s:%(lineno)d)" if verbose else ""),
252+
datefmt="%Y-%m-%d %H:%M:%S",
253+
)
203254
logging.getLogger("urllib3").setLevel(logging.WARNING)
204255

205256

@@ -216,9 +267,10 @@ def threshold_alerts():
216267
Checks elapsed time for each category and triggers alerts when thresholds are reached.
217268
"""
218269
alerts = [
219-
CategoryAlert("", [td1h, td2h, td4h, td6h, td8h]),
220-
CategoryAlert("Twitter", [td15min, td30min, td1h]),
221-
CategoryAlert("Work", [td15min, td30min, td1h, td2h, td4h]),
270+
CategoryAlert("All", [td1h, td2h, td4h, td6h, td8h], label="All"),
271+
CategoryAlert("Twitter", [td15min, td30min, td1h], label="🐦 Twitter"),
272+
CategoryAlert("Youtube", [td15min, td30min, td1h], label="📺 Youtube"),
273+
CategoryAlert("Work", [td15min, td30min, td1h, td2h, td4h], label="💼 Work"),
222274
]
223275

224276
while True:
@@ -227,7 +279,7 @@ def threshold_alerts():
227279
alert.check()
228280
status = alert.status()
229281
if status != getattr(alert, "last_status", None):
230-
logger.info(f"New status: {status}")
282+
logger.debug(f"New status: {status}")
231283
alert.last_status = status
232284

233285
sleep(10)
@@ -245,12 +297,13 @@ def checkin():
245297
Meant to be sent at a particular time, like at the end of a working day (e.g. 5pm).
246298
"""
247299
# TODO: load categories from data
248-
top_categories = [""] + [k[0] for k, _ in CATEGORIES]
249-
time_spent = [get_time(c) for c in top_categories]
250-
msg = f"Time spent today: {sum(time_spent, timedelta())}\n"
300+
top_categories = ["All"] + [k[0] for k, _ in CATEGORIES]
301+
cat_time = get_time()
302+
time_spent = [cat_time[c] for c in top_categories]
303+
msg = f"Time spent today: {to_hms(time_spent[0])}\n"
251304
msg += "Categories:\n"
252305
msg += "\n".join(
253-
f" - {c if c else 'All'}: {t}"
306+
f" - {c if c else 'All'}: {to_hms(t)}"
254307
for c, t in sorted(
255308
zip(top_categories, time_spent), key=lambda x: x[1], reverse=True
256309
)
@@ -267,11 +320,12 @@ def get_active_status() -> bool:
267320

268321
hostname = aw.get_info().get("hostname", "unknown")
269322
events = aw.get_events(f"aw-watcher-afk_{hostname}", limit=1)
270-
print(events)
323+
logger.debug(events)
271324
if not events:
272325
return None
273326
event = events[0]
274-
if event.timestamp < datetime.now(timezone.utc) - timedelta(minutes=5):
327+
event_end = event.timestamp + event.duration
328+
if event_end < datetime.now(timezone.utc) - timedelta(minutes=5):
275329
# event is too old
276330
logger.warning(
277331
"AFK event is too old, can't use to reliably determine AFK state"
@@ -295,7 +349,11 @@ def checkin_thread():
295349
sleep(sleep_time)
296350

297351
# check if user is afk
298-
active = get_active_status()
352+
try:
353+
active = get_active_status()
354+
except Exception as e:
355+
logger.warning(f"Error getting AFK status: {e}")
356+
continue
299357
if active is None:
300358
logger.warning("Can't determine AFK status, skipping hourly checkin")
301359
continue

0 commit comments

Comments
 (0)