From ea6378c2d424bbdedf70c98fdf04499a625ea419 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Tue, 19 Dec 2023 18:40:55 +0100 Subject: [PATCH] event listeners --- bugsink/period_counter.py | 83 ++++++++++++++++++++++++++++++++------- bugsink/tests.py | 59 ++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 15 deletions(-) diff --git a/bugsink/period_counter.py b/bugsink/period_counter.py index 0ecd14e..1ac74d5 100644 --- a/bugsink/period_counter.py +++ b/bugsink/period_counter.py @@ -9,12 +9,22 @@ MAX_HOURS = 24 MAX_DAYS = 30 MAX_MONTHS = 12 MAX_YEARS = 5 +MAX_TOTALS = 1 FOO_MIN = 1000, 1, 1, 0, 0 FOO_MAX = 3000, 12, "?", 23, 59 +# TL for "tuple length", the length of the tuples for a given time-period +TL_TOTAL = 0 +TL_YEAR = 1 +TL_MONTH = 2 +TL_DAY = 3 +TL_HOUR = 4 +TL_MINUTE = 5 + + def apply_n(f, n, v): for i in range(n): v = f(v) @@ -43,35 +53,78 @@ def _prev_tup(tup): def _inc(d, tup, n, max_age): + new_period = False + if tup not in d: - # evict - min_tup = apply_n(_prev_tup, max_age, tup) - d = {k: v for k, v in d.items() if d >= min_tup} + if len(d) > 0: + new_period = True + min_tup = apply_n(_prev_tup, max_age - 1, tup) + for k, v in list(d.items()): + if k < min_tup: + del d[k] # default d[tup] = 0 # inc d[tup] += n + return new_period class PeriodCounter(object): def __init__(self): - self.total = 0 - self.years = {} - self.months = {} - self.days = {} - self.hours = {} - self.minutes = {} + self.counts = {tuple_length: {} for tuple_length in range(TL_MINUTE + 1)} + self.event_listeners = {tuple_length: {} for tuple_length in range(TL_MINUTE + 1)} def inc(self, datetime_utc, n=1): tup = datetime_utc.timetuple() - self.total += n # self.forevers, () + for tl, mx in enumerate([MAX_TOTALS, MAX_YEARS, MAX_MONTHS, MAX_DAYS, MAX_HOURS, MAX_MINUTES]): + new_period = _inc(self.counts[tl], tup[:tl], n, mx) - _inc(self.years, tup[:1], n, MAX_YEARS) - _inc(self.months, tup[:2], n, MAX_MONTHS) - _inc(self.days, tup[:3], n, MAX_DAYS) - _inc(self.hours, tup[:4], n, MAX_HOURS) - _inc(self.minutes, tup[:5], n, MAX_MINUTES) + event_listeners_for_tl = self.event_listeners[tl] + for ((how_many_periods, gte_threshold), (wbt, wbf, is_true)) in list(event_listeners_for_tl.items()): + if is_true: + if not new_period: + continue # no new period means: never becomes false, because no old period becomes irrelevant + + if not self._get_event_state(tup[:tl], tl, how_many_periods, gte_threshold): + event_listeners_for_tl[(how_many_periods, gte_threshold)] = (wbt, wbf, False) + wbf() + + else: + if self._get_event_state(tup[:tl], tl, how_many_periods, gte_threshold): + event_listeners_for_tl[(how_many_periods, gte_threshold)] = (wbt, wbf, True) + wbt() + + def add_event_listener(self, period_name, how_many_periods, gte_threshold, when_becomes_true, when_becomes_false, + event_state=None, tup=None): + + if len([arg for arg in [event_state, tup] if arg is None]) != 1: + # either be explicit, or let us deduce + raise ValueError("Provide exactly one of (event_state, tup)") + + tl = self._tl_for_period(period_name) + if event_state is None: + event_state = self._get_event_state(tup, tl, how_many_periods, gte_threshold) + + self.event_listeners[tl][(how_many_periods, gte_threshold)] = \ + (when_becomes_true, when_becomes_false, event_state) + + def _tl_for_period(self, period_name): + return { + "total": 0, + "year": 1, + "month": 2, + "day": 3, + "hour": 4, + "minute": 5, + }[period_name] + + def _get_event_state(self, tup, tl, how_many_periods, gte_threshold): + min_tup = apply_n(_prev_tup, how_many_periods - 1, tup) + d = self.counts[tl] + total = sum([v for k, v in d.items() if k >= min_tup]) + + return total >= gte_threshold diff --git a/bugsink/tests.py b/bugsink/tests.py index 64d18cb..25d3163 100644 --- a/bugsink/tests.py +++ b/bugsink/tests.py @@ -11,6 +11,14 @@ def apply_n(f, n, v): return v +class callback(object): + def __init__(self): + self.calls = 0 + + def __call__(self): + self.calls += 1 + + class PeriodCounterTestCase(TestCase): def test_prev_tup(self): @@ -42,3 +50,54 @@ class PeriodCounterTestCase(TestCase): datetime_utc = datetime.now(timezone.utc) # basically I just want to write this down somewhere pc = PeriodCounter() pc.inc(datetime_utc) + + def test_event_listeners_for_total(self): + timepoint = datetime(2020, 1, 1, 10, 15, tzinfo=timezone.utc) + + pc = PeriodCounter() + wbt = callback() + wbf = callback() + pc.add_event_listener("total", 1, 2, wbt, wbf, event_state=False) + + # first inc: should not yet trigger + pc.inc(timepoint) + self.assertEquals(0, wbt.calls) + + # second inc: should trigger (threshold of 2) + pc.inc(timepoint) + self.assertEquals(1, wbt.calls) + + # third inc: should not trigger again + pc.inc(timepoint) + self.assertEquals(1, wbt.calls) + + def test_event_listeners_for_year(self): + tp_2020 = datetime(2020, 1, 1, 10, 15, tzinfo=timezone.utc) + tp_2021 = datetime(2021, 1, 1, 10, 15, tzinfo=timezone.utc) + tp_2022 = datetime(2022, 1, 1, 10, 15, tzinfo=timezone.utc) + + pc = PeriodCounter() + wbt = callback() + wbf = callback() + pc.add_event_listener("year", 2, 3, wbt, wbf, event_state=False) + + pc.inc(tp_2020) + self.assertEquals(0, wbt.calls) + + pc.inc(tp_2020) + self.assertEquals(0, wbt.calls) + + # 3rd in total: become True + pc.inc(tp_2021) + self.assertEquals(1, wbt.calls) + + # into a new year, total == 2: become false + self.assertEquals(0, wbf.calls) + pc.inc(tp_2022) + self.assertEquals(1, wbf.calls) + self.assertEquals(1, wbt.calls) # unchanged + + # 3rd in (new) total: become True again + pc.inc(tp_2022) + self.assertEquals(2, wbt.calls) + self.assertEquals(1, wbf.calls) # unchanged