diff --git a/lib/tempora/__init__.py b/lib/tempora/__init__.py new file mode 100644 index 00000000..afcb4441 --- /dev/null +++ b/lib/tempora/__init__.py @@ -0,0 +1,506 @@ +# -*- coding: UTF-8 -*- + +"Objects and routines pertaining to date and time (tempora)" + +from __future__ import division, unicode_literals + +import datetime +import time +import re +import numbers +import functools + +import six + +__metaclass__ = type + + +class Parser: + """ + Datetime parser: parses a date-time string using multiple possible + formats. + + >>> p = Parser(('%H%M', '%H:%M')) + >>> tuple(p.parse('1319')) + (1900, 1, 1, 13, 19, 0, 0, 1, -1) + >>> dateParser = Parser(('%m/%d/%Y', '%Y-%m-%d', '%d-%b-%Y')) + >>> tuple(dateParser.parse('2003-12-20')) + (2003, 12, 20, 0, 0, 0, 5, 354, -1) + >>> tuple(dateParser.parse('16-Dec-1994')) + (1994, 12, 16, 0, 0, 0, 4, 350, -1) + >>> tuple(dateParser.parse('5/19/2003')) + (2003, 5, 19, 0, 0, 0, 0, 139, -1) + >>> dtParser = Parser(('%Y-%m-%d %H:%M:%S', '%a %b %d %H:%M:%S %Y')) + >>> tuple(dtParser.parse('2003-12-20 19:13:26')) + (2003, 12, 20, 19, 13, 26, 5, 354, -1) + >>> tuple(dtParser.parse('Tue Jan 20 16:19:33 2004')) + (2004, 1, 20, 16, 19, 33, 1, 20, -1) + + Be forewarned, a ValueError will be raised if more than one format + matches: + + >>> Parser(('%H%M', '%H%M%S')).parse('732') + Traceback (most recent call last): + ... + ValueError: More than one format string matched target 732. + """ + + formats = ('%m/%d/%Y', '%m/%d/%y', '%Y-%m-%d', '%d-%b-%Y', '%d-%b-%y') + "some common default formats" + + def __init__(self, formats=None): + if formats: + self.formats = formats + + def parse(self, target): + self.target = target + results = tuple(filter(None, map(self._parse, self.formats))) + del self.target + if not results: + tmpl = "No format strings matched the target {target}." + raise ValueError(tmpl.format(**locals())) + if not len(results) == 1: + tmpl = "More than one format string matched target {target}." + raise ValueError(tmpl.format(**locals())) + return results[0] + + def _parse(self, format): + try: + result = time.strptime(self.target, format) + except ValueError: + result = False + return result + + +# some useful constants +osc_per_year = 290091329207984000 +""" +mean vernal equinox year expressed in oscillations of atomic cesium at the +year 2000 (see http://webexhibits.org/calendars/timeline.html for more info). +""" +osc_per_second = 9192631770 +seconds_per_second = 1 +seconds_per_year = 31556940 +seconds_per_minute = 60 +minutes_per_hour = 60 +hours_per_day = 24 +seconds_per_hour = seconds_per_minute * minutes_per_hour +seconds_per_day = seconds_per_hour * hours_per_day +days_per_year = seconds_per_year / seconds_per_day +thirty_days = datetime.timedelta(days=30) +# these values provide useful averages +six_months = datetime.timedelta(days=days_per_year / 2) +seconds_per_month = seconds_per_year / 12 +hours_per_month = hours_per_day * days_per_year / 12 + + +def strftime(fmt, t): + """A class to replace the strftime in datetime package or time module. + Identical to strftime behavior in those modules except supports any + year. + Also supports datetime.datetime times. + Also supports milliseconds using %s + Also supports microseconds using %u""" + if isinstance(t, (time.struct_time, tuple)): + t = datetime.datetime(*t[:6]) + assert isinstance(t, (datetime.datetime, datetime.time, datetime.date)) + try: + year = t.year + if year < 1900: + t = t.replace(year=1900) + except AttributeError: + year = 1900 + subs = ( + ('%Y', '%04d' % year), + ('%y', '%02d' % (year % 100)), + ('%s', '%03d' % (t.microsecond // 1000)), + ('%u', '%03d' % (t.microsecond % 1000)) + ) + + def doSub(s, sub): + return s.replace(*sub) + + def doSubs(s): + return functools.reduce(doSub, subs, s) + + fmt = '%%'.join(map(doSubs, fmt.split('%%'))) + return t.strftime(fmt) + + +def strptime(s, fmt, tzinfo=None): + """ + A function to replace strptime in the time module. Should behave + identically to the strptime function except it returns a datetime.datetime + object instead of a time.struct_time object. + Also takes an optional tzinfo parameter which is a time zone info object. + """ + res = time.strptime(s, fmt) + return datetime.datetime(tzinfo=tzinfo, *res[:6]) + + +class DatetimeConstructor: + """ + >>> cd = DatetimeConstructor.construct_datetime + >>> cd(datetime.datetime(2011,1,1)) + datetime.datetime(2011, 1, 1, 0, 0) + """ + @classmethod + def construct_datetime(cls, *args, **kwargs): + """Construct a datetime.datetime from a number of different time + types found in python and pythonwin""" + if len(args) == 1: + arg = args[0] + method = cls.__get_dt_constructor( + type(arg).__module__, + type(arg).__name__, + ) + result = method(arg) + try: + result = result.replace(tzinfo=kwargs.pop('tzinfo')) + except KeyError: + pass + if kwargs: + first_key = kwargs.keys()[0] + tmpl = ( + "{first_key} is an invalid keyword " + "argument for this function." + ) + raise TypeError(tmpl.format(**locals())) + else: + result = datetime.datetime(*args, **kwargs) + return result + + @classmethod + def __get_dt_constructor(cls, moduleName, name): + try: + method_name = '__dt_from_{moduleName}_{name}__'.format(**locals()) + return getattr(cls, method_name) + except AttributeError: + tmpl = ( + "No way to construct datetime.datetime from " + "{moduleName}.{name}" + ) + raise TypeError(tmpl.format(**locals())) + + @staticmethod + def __dt_from_datetime_datetime__(source): + dtattrs = ( + 'year', 'month', 'day', 'hour', 'minute', 'second', + 'microsecond', 'tzinfo', + ) + attrs = map(lambda a: getattr(source, a), dtattrs) + return datetime.datetime(*attrs) + + @staticmethod + def __dt_from___builtin___time__(pyt): + "Construct a datetime.datetime from a pythonwin time" + fmtString = '%Y-%m-%d %H:%M:%S' + result = strptime(pyt.Format(fmtString), fmtString) + # get milliseconds and microseconds. The only way to do this is + # to use the __float__ attribute of the time, which is in days. + microseconds_per_day = seconds_per_day * 1000000 + microseconds = float(pyt) * microseconds_per_day + microsecond = int(microseconds % 1000000) + result = result.replace(microsecond=microsecond) + return result + + @staticmethod + def __dt_from_timestamp__(timestamp): + return datetime.datetime.utcfromtimestamp(timestamp) + __dt_from___builtin___float__ = __dt_from_timestamp__ + __dt_from___builtin___long__ = __dt_from_timestamp__ + __dt_from___builtin___int__ = __dt_from_timestamp__ + + @staticmethod + def __dt_from_time_struct_time__(s): + return datetime.datetime(*s[:6]) + + +def datetime_mod(dt, period, start=None): + """ + Find the time which is the specified date/time truncated to the time delta + relative to the start date/time. + By default, the start time is midnight of the same day as the specified + date/time. + + >>> datetime_mod(datetime.datetime(2004, 1, 2, 3), + ... datetime.timedelta(days = 1.5), + ... start = datetime.datetime(2004, 1, 1)) + datetime.datetime(2004, 1, 1, 0, 0) + >>> datetime_mod(datetime.datetime(2004, 1, 2, 13), + ... datetime.timedelta(days = 1.5), + ... start = datetime.datetime(2004, 1, 1)) + datetime.datetime(2004, 1, 2, 12, 0) + >>> datetime_mod(datetime.datetime(2004, 1, 2, 13), + ... datetime.timedelta(days = 7), + ... start = datetime.datetime(2004, 1, 1)) + datetime.datetime(2004, 1, 1, 0, 0) + >>> datetime_mod(datetime.datetime(2004, 1, 10, 13), + ... datetime.timedelta(days = 7), + ... start = datetime.datetime(2004, 1, 1)) + datetime.datetime(2004, 1, 8, 0, 0) + """ + if start is None: + # use midnight of the same day + start = datetime.datetime.combine(dt.date(), datetime.time()) + # calculate the difference between the specified time and the start date. + delta = dt - start + + # now aggregate the delta and the period into microseconds + # Use microseconds because that's the highest precision of these time + # pieces. Also, using microseconds ensures perfect precision (no floating + # point errors). + def get_time_delta_microseconds(td): + return (td.days * seconds_per_day + td.seconds) * 1000000 + td.microseconds + delta, period = map(get_time_delta_microseconds, (delta, period)) + offset = datetime.timedelta(microseconds=delta % period) + # the result is the original specified time minus the offset + result = dt - offset + return result + + +def datetime_round(dt, period, start=None): + """ + Find the nearest even period for the specified date/time. + + >>> datetime_round(datetime.datetime(2004, 11, 13, 8, 11, 13), + ... datetime.timedelta(hours = 1)) + datetime.datetime(2004, 11, 13, 8, 0) + >>> datetime_round(datetime.datetime(2004, 11, 13, 8, 31, 13), + ... datetime.timedelta(hours = 1)) + datetime.datetime(2004, 11, 13, 9, 0) + >>> datetime_round(datetime.datetime(2004, 11, 13, 8, 30), + ... datetime.timedelta(hours = 1)) + datetime.datetime(2004, 11, 13, 9, 0) + """ + result = datetime_mod(dt, period, start) + if abs(dt - result) >= period // 2: + result += period + return result + + +def get_nearest_year_for_day(day): + """ + Returns the nearest year to now inferred from a Julian date. + """ + now = time.gmtime() + result = now.tm_year + # if the day is far greater than today, it must be from last year + if day - now.tm_yday > 365 // 2: + result -= 1 + # if the day is far less than today, it must be for next year. + if now.tm_yday - day > 365 // 2: + result += 1 + return result + + +def gregorian_date(year, julian_day): + """ + Gregorian Date is defined as a year and a julian day (1-based + index into the days of the year). + + >>> gregorian_date(2007, 15) + datetime.date(2007, 1, 15) + """ + result = datetime.date(year, 1, 1) + result += datetime.timedelta(days=julian_day - 1) + return result + + +def get_period_seconds(period): + """ + return the number of seconds in the specified period + + >>> get_period_seconds('day') + 86400 + >>> get_period_seconds(86400) + 86400 + >>> get_period_seconds(datetime.timedelta(hours=24)) + 86400 + >>> get_period_seconds('day + os.system("rm -Rf *")') + Traceback (most recent call last): + ... + ValueError: period not in (second, minute, hour, day, month, year) + """ + if isinstance(period, six.string_types): + try: + name = 'seconds_per_' + period.lower() + result = globals()[name] + except KeyError: + msg = "period not in (second, minute, hour, day, month, year)" + raise ValueError(msg) + elif isinstance(period, numbers.Number): + result = period + elif isinstance(period, datetime.timedelta): + result = period.days * get_period_seconds('day') + period.seconds + else: + raise TypeError('period must be a string or integer') + return result + + +def get_date_format_string(period): + """ + For a given period (e.g. 'month', 'day', or some numeric interval + such as 3600 (in secs)), return the format string that can be + used with strftime to format that time to specify the times + across that interval, but no more detailed. + For example, + + >>> get_date_format_string('month') + '%Y-%m' + >>> get_date_format_string(3600) + '%Y-%m-%d %H' + >>> get_date_format_string('hour') + '%Y-%m-%d %H' + >>> get_date_format_string(None) + Traceback (most recent call last): + ... + TypeError: period must be a string or integer + >>> get_date_format_string('garbage') + Traceback (most recent call last): + ... + ValueError: period not in (second, minute, hour, day, month, year) + """ + # handle the special case of 'month' which doesn't have + # a static interval in seconds + if isinstance(period, six.string_types) and period.lower() == 'month': + return '%Y-%m' + file_period_secs = get_period_seconds(period) + format_pieces = ('%Y', '-%m-%d', ' %H', '-%M', '-%S') + seconds_per_second = 1 + intervals = ( + seconds_per_year, + seconds_per_day, + seconds_per_hour, + seconds_per_minute, + seconds_per_second, + ) + mods = list(map(lambda interval: file_period_secs % interval, intervals)) + format_pieces = format_pieces[: mods.index(0) + 1] + return ''.join(format_pieces) + + +def divide_timedelta_float(td, divisor): + """ + Divide a timedelta by a float value + + >>> one_day = datetime.timedelta(days=1) + >>> half_day = datetime.timedelta(days=.5) + >>> divide_timedelta_float(one_day, 2.0) == half_day + True + >>> divide_timedelta_float(one_day, 2) == half_day + True + """ + # td is comprised of days, seconds, microseconds + dsm = [getattr(td, attr) for attr in ('days', 'seconds', 'microseconds')] + dsm = map(lambda elem: elem / divisor, dsm) + return datetime.timedelta(*dsm) + + +def calculate_prorated_values(): + """ + A utility function to prompt for a rate (a string in units per + unit time), and return that same rate for various time periods. + """ + rate = six.moves.input("Enter the rate (3/hour, 50/month)> ") + res = re.match(r'(?P[\d.]+)/(?P\w+)$', rate).groupdict() + value = float(res['value']) + value_per_second = value / get_period_seconds(res['period']) + for period in ('minute', 'hour', 'day', 'month', 'year'): + period_value = value_per_second * get_period_seconds(period) + print("per {period}: {period_value}".format(**locals())) + + +def parse_timedelta(str): + """ + Take a string representing a span of time and parse it to a time delta. + Accepts any string of comma-separated numbers each with a unit indicator. + + >>> parse_timedelta('1 day') + datetime.timedelta(days=1) + + >>> parse_timedelta('1 day, 30 seconds') + datetime.timedelta(days=1, seconds=30) + + >>> parse_timedelta('47.32 days, 20 minutes, 15.4 milliseconds') + datetime.timedelta(days=47, seconds=28848, microseconds=15400) + + Supports weeks, months, years + + >>> parse_timedelta('1 week') + datetime.timedelta(days=7) + + >>> parse_timedelta('1 year, 1 month') + datetime.timedelta(days=395, seconds=58685) + + Note that months and years strict intervals, not aligned + to a calendar: + + >>> now = datetime.datetime.now() + >>> later = now + parse_timedelta('1 year') + >>> diff = later.replace(year=now.year) - now + >>> diff.seconds + 20940 + """ + deltas = (_parse_timedelta_part(part.strip()) for part in str.split(',')) + return sum(deltas, datetime.timedelta()) + + +def _parse_timedelta_part(part): + match = re.match(r'(?P[\d.]+) (?P\w+)', part) + if not match: + msg = "Unable to parse {part!r} as a time delta".format(**locals()) + raise ValueError(msg) + unit = match.group('unit').lower() + if not unit.endswith('s'): + unit += 's' + value = float(match.group('value')) + if unit == 'months': + unit = 'years' + value = value / 12 + if unit == 'years': + unit = 'days' + value = value * days_per_year + return datetime.timedelta(**{unit: value}) + + +def divide_timedelta(td1, td2): + """ + Get the ratio of two timedeltas + + >>> one_day = datetime.timedelta(days=1) + >>> one_hour = datetime.timedelta(hours=1) + >>> divide_timedelta(one_hour, one_day) == 1 / 24 + True + """ + try: + return td1 / td2 + except TypeError: + # Python 3.2 gets division + # http://bugs.python.org/issue2706 + return td1.total_seconds() / td2.total_seconds() + + +def date_range(start=None, stop=None, step=None): + """ + Much like the built-in function range, but works with dates + + >>> range_items = date_range( + ... datetime.datetime(2005,12,21), + ... datetime.datetime(2005,12,25), + ... ) + >>> my_range = tuple(range_items) + >>> datetime.datetime(2005,12,21) in my_range + True + >>> datetime.datetime(2005,12,22) in my_range + True + >>> datetime.datetime(2005,12,25) in my_range + False + """ + if step is None: + step = datetime.timedelta(days=1) + if start is None: + start = datetime.datetime.now() + while start < stop: + yield start + start += step diff --git a/lib/tempora/schedule.py b/lib/tempora/schedule.py new file mode 100644 index 00000000..1ad093b2 --- /dev/null +++ b/lib/tempora/schedule.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- + +""" +Classes for calling functions a schedule. +""" + +from __future__ import absolute_import + +import datetime +import numbers +import abc +import bisect + +import pytz + +__metaclass__ = type + + +def now(): + """ + Provide the current timezone-aware datetime. + + A client may override this function to change the default behavior, + such as to use local time or timezone-naïve times. + """ + return datetime.datetime.utcnow().replace(tzinfo=pytz.utc) + + +def from_timestamp(ts): + """ + Convert a numeric timestamp to a timezone-aware datetime. + + A client may override this function to change the default behavior, + such as to use local time or timezone-naïve times. + """ + return datetime.datetime.utcfromtimestamp(ts).replace(tzinfo=pytz.utc) + + +class DelayedCommand(datetime.datetime): + """ + A command to be executed after some delay (seconds or timedelta). + """ + + @classmethod + def from_datetime(cls, other): + return cls( + other.year, other.month, other.day, other.hour, + other.minute, other.second, other.microsecond, + other.tzinfo, + ) + + @classmethod + def after(cls, delay, target): + if not isinstance(delay, datetime.timedelta): + delay = datetime.timedelta(seconds=delay) + due_time = now() + delay + cmd = cls.from_datetime(due_time) + cmd.delay = delay + cmd.target = target + return cmd + + @staticmethod + def _from_timestamp(input): + """ + If input is a real number, interpret it as a Unix timestamp + (seconds sinc Epoch in UTC) and return a timezone-aware + datetime object. Otherwise return input unchanged. + """ + if not isinstance(input, numbers.Real): + return input + return from_timestamp(input) + + @classmethod + def at_time(cls, at, target): + """ + Construct a DelayedCommand to come due at `at`, where `at` may be + a datetime or timestamp. + """ + at = cls._from_timestamp(at) + cmd = cls.from_datetime(at) + cmd.delay = at - now() + cmd.target = target + return cmd + + def due(self): + return now() >= self + + +class PeriodicCommand(DelayedCommand): + """ + Like a delayed command, but expect this command to run every delay + seconds. + """ + def _next_time(self): + """ + Add delay to self, localized + """ + return self._localize(self + self.delay) + + @staticmethod + def _localize(dt): + """ + Rely on pytz.localize to ensure new result honors DST. + """ + try: + tz = dt.tzinfo + return tz.localize(dt.replace(tzinfo=None)) + except AttributeError: + return dt + + def next(self): + cmd = self.__class__.from_datetime(self._next_time()) + cmd.delay = self.delay + cmd.target = self.target + return cmd + + def __setattr__(self, key, value): + if key == 'delay' and not value > datetime.timedelta(): + raise ValueError( + "A PeriodicCommand must have a positive, " + "non-zero delay." + ) + super(PeriodicCommand, self).__setattr__(key, value) + + +class PeriodicCommandFixedDelay(PeriodicCommand): + """ + Like a periodic command, but don't calculate the delay based on + the current time. Instead use a fixed delay following the initial + run. + """ + + @classmethod + def at_time(cls, at, delay, target): + at = cls._from_timestamp(at) + cmd = cls.from_datetime(at) + if isinstance(delay, numbers.Number): + delay = datetime.timedelta(seconds=delay) + cmd.delay = delay + cmd.target = target + return cmd + + @classmethod + def daily_at(cls, at, target): + """ + Schedule a command to run at a specific time each day. + """ + daily = datetime.timedelta(days=1) + # convert when to the next datetime matching this time + when = datetime.datetime.combine(datetime.date.today(), at) + if when < now(): + when += daily + return cls.at_time(cls._localize(when), daily, target) + + +class Scheduler: + """ + A rudimentary abstract scheduler accepting DelayedCommands + and dispatching them on schedule. + """ + def __init__(self): + self.queue = [] + + def add(self, command): + assert isinstance(command, DelayedCommand) + bisect.insort(self.queue, command) + + def run_pending(self): + while self.queue: + command = self.queue[0] + if not command.due(): + break + self.run(command) + if isinstance(command, PeriodicCommand): + self.add(command.next()) + del self.queue[0] + + @abc.abstractmethod + def run(self, command): + """ + Run the command + """ + + +class InvokeScheduler(Scheduler): + """ + Command targets are functions to be invoked on schedule. + """ + def run(self, command): + command.target() + + +class CallbackScheduler(Scheduler): + """ + Command targets are passed to a dispatch callable on schedule. + """ + def __init__(self, dispatch): + super(CallbackScheduler, self).__init__() + self.dispatch = dispatch + + def run(self, command): + self.dispatch(command.target) diff --git a/lib/tempora/tests/test_schedule.py b/lib/tempora/tests/test_schedule.py new file mode 100644 index 00000000..38eb8dc9 --- /dev/null +++ b/lib/tempora/tests/test_schedule.py @@ -0,0 +1,118 @@ +import time +import random +import datetime + +import pytest +import pytz +import freezegun + +from tempora import schedule + +__metaclass__ = type + + +@pytest.fixture +def naive_times(monkeypatch): + monkeypatch.setattr( + 'irc.schedule.from_timestamp', + datetime.datetime.fromtimestamp) + monkeypatch.setattr('irc.schedule.now', datetime.datetime.now) + + +do_nothing = type(None) +try: + do_nothing() +except TypeError: + # Python 2 compat + def do_nothing(): + return None + + +def test_delayed_command_order(): + """ + delayed commands should be sorted by delay time + """ + delays = [random.randint(0, 99) for x in range(5)] + cmds = sorted([ + schedule.DelayedCommand.after(delay, do_nothing) + for delay in delays + ]) + assert [c.delay.seconds for c in cmds] == sorted(delays) + + +def test_periodic_command_delay(): + "A PeriodicCommand must have a positive, non-zero delay." + with pytest.raises(ValueError) as exc_info: + schedule.PeriodicCommand.after(0, None) + assert str(exc_info.value) == test_periodic_command_delay.__doc__ + + +def test_periodic_command_fixed_delay(): + """ + Test that we can construct a periodic command with a fixed initial + delay. + """ + fd = schedule.PeriodicCommandFixedDelay.at_time( + at=schedule.now(), + delay=datetime.timedelta(seconds=2), + target=lambda: None, + ) + assert fd.due() is True + assert fd.next().due() is False + + +class TestCommands: + def test_delayed_command_from_timestamp(self): + """ + Ensure a delayed command can be constructed from a timestamp. + """ + t = time.time() + schedule.DelayedCommand.at_time(t, do_nothing) + + def test_command_at_noon(self): + """ + Create a periodic command that's run at noon every day. + """ + when = datetime.time(12, 0, tzinfo=pytz.utc) + cmd = schedule.PeriodicCommandFixedDelay.daily_at(when, target=None) + assert cmd.due() is False + next_cmd = cmd.next() + daily = datetime.timedelta(days=1) + day_from_now = schedule.now() + daily + two_days_from_now = day_from_now + daily + assert day_from_now < next_cmd < two_days_from_now + + +class TestTimezones: + def test_alternate_timezone_west(self): + target_tz = pytz.timezone('US/Pacific') + target = schedule.now().astimezone(target_tz) + cmd = schedule.DelayedCommand.at_time(target, target=None) + assert cmd.due() + + def test_alternate_timezone_east(self): + target_tz = pytz.timezone('Europe/Amsterdam') + target = schedule.now().astimezone(target_tz) + cmd = schedule.DelayedCommand.at_time(target, target=None) + assert cmd.due() + + def test_daylight_savings(self): + """ + A command at 9am should always be 9am regardless of + a DST boundary. + """ + with freezegun.freeze_time('2018-03-10 08:00:00'): + target_tz = pytz.timezone('US/Eastern') + target_time = datetime.time(9, tzinfo=target_tz) + cmd = schedule.PeriodicCommandFixedDelay.daily_at( + target_time, + target=lambda: None, + ) + + def naive(dt): + return dt.replace(tzinfo=None) + + assert naive(cmd) == datetime.datetime(2018, 3, 10, 9, 0, 0) + next_ = cmd.next() + assert naive(next_) == datetime.datetime(2018, 3, 11, 9, 0, 0) + assert next_ - cmd == datetime.timedelta(hours=23) diff --git a/lib/tempora/timing.py b/lib/tempora/timing.py new file mode 100644 index 00000000..2835ddc2 --- /dev/null +++ b/lib/tempora/timing.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals, absolute_import + +import datetime +import functools +import numbers +import time + +import six + +import jaraco.functools + + +__metaclass__ = type + + +class Stopwatch: + """ + A simple stopwatch which starts automatically. + + >>> w = Stopwatch() + >>> _1_sec = datetime.timedelta(seconds=1) + >>> w.split() < _1_sec + True + >>> import time + >>> time.sleep(1.0) + >>> w.split() >= _1_sec + True + >>> w.stop() >= _1_sec + True + >>> w.reset() + >>> w.start() + >>> w.split() < _1_sec + True + + It should be possible to launch the Stopwatch in a context: + + >>> with Stopwatch() as watch: + ... assert isinstance(watch.split(), datetime.timedelta) + + In that case, the watch is stopped when the context is exited, + so to read the elapsed time:: + + >>> watch.elapsed + datetime.timedelta(...) + >>> watch.elapsed.seconds + 0 + """ + def __init__(self): + self.reset() + self.start() + + def reset(self): + self.elapsed = datetime.timedelta(0) + if hasattr(self, 'start_time'): + del self.start_time + + def start(self): + self.start_time = datetime.datetime.utcnow() + + def stop(self): + stop_time = datetime.datetime.utcnow() + self.elapsed += stop_time - self.start_time + del self.start_time + return self.elapsed + + def split(self): + local_duration = datetime.datetime.utcnow() - self.start_time + return self.elapsed + local_duration + + # context manager support + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.stop() + + +class IntervalGovernor: + """ + Decorate a function to only allow it to be called once per + min_interval. Otherwise, it returns None. + """ + def __init__(self, min_interval): + if isinstance(min_interval, numbers.Number): + min_interval = datetime.timedelta(seconds=min_interval) + self.min_interval = min_interval + self.last_call = None + + def decorate(self, func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + allow = ( + not self.last_call + or self.last_call.split() > self.min_interval + ) + if allow: + self.last_call = Stopwatch() + return func(*args, **kwargs) + return wrapper + + __call__ = decorate + + +class Timer(Stopwatch): + """ + Watch for a target elapsed time. + + >>> t = Timer(0.1) + >>> t.expired() + False + >>> __import__('time').sleep(0.15) + >>> t.expired() + True + """ + def __init__(self, target=float('Inf')): + self.target = self._accept(target) + super(Timer, self).__init__() + + def _accept(self, target): + "Accept None or ∞ or datetime or numeric for target" + if isinstance(target, datetime.timedelta): + target = target.total_seconds() + + if target is None: + # treat None as infinite target + target = float('Inf') + + return target + + def expired(self): + return self.split().total_seconds() > self.target + + +class BackoffDelay(six.Iterator): + """ + Exponential backoff delay. + + Useful for defining delays between retries. Consider for use + with ``jaraco.functools.retry_call`` as the cleanup. + + Default behavior has no effect; a delay or jitter must + be supplied for the call to be non-degenerate. + + >>> bd = BackoffDelay() + >>> bd() + >>> bd() + + The following instance will delay 10ms for the first call, + 20ms for the second, etc. + + >>> bd = BackoffDelay(delay=0.01, factor=2) + >>> bd() + >>> bd() + + Inspect and adjust the state of the delay anytime. + + >>> bd.delay + 0.04 + >>> bd.delay = 0.01 + + Set limit to prevent the delay from exceeding bounds. + + >>> bd = BackoffDelay(delay=0.01, factor=2, limit=0.015) + >>> bd() + >>> bd.delay + 0.015 + + To reset the backoff, simply call ``.reset()``: + + >>> bd.reset() + >>> bd.delay + 0.01 + + Iterate on the object to retrieve/advance the delay values. + + >>> next(bd) + 0.01 + >>> next(bd) + 0.015 + >>> import itertools + >>> tuple(itertools.islice(bd, 3)) + (0.015, 0.015, 0.015) + + Limit may be a callable taking a number and returning + the limited number. + + >>> at_least_one = lambda n: max(n, 1) + >>> bd = BackoffDelay(delay=0.01, factor=2, limit=at_least_one) + >>> next(bd) + 0.01 + >>> next(bd) + 1 + + Pass a jitter to add or subtract seconds to the delay. + + >>> bd = BackoffDelay(jitter=0.01) + >>> next(bd) + 0 + >>> next(bd) + 0.01 + + Jitter may be a callable. To supply a non-deterministic jitter + between -0.5 and 0.5, consider: + + >>> import random + >>> jitter=functools.partial(random.uniform, -0.5, 0.5) + >>> bd = BackoffDelay(jitter=jitter) + >>> next(bd) + 0 + >>> 0 <= next(bd) <= 0.5 + True + """ + + delay = 0 + + factor = 1 + "Multiplier applied to delay" + + jitter = 0 + "Number or callable returning extra seconds to add to delay" + + @jaraco.functools.save_method_args + def __init__(self, delay=0, factor=1, limit=float('inf'), jitter=0): + self.delay = delay + self.factor = factor + if isinstance(limit, numbers.Number): + limit_ = limit + + def limit(n): + return max(0, min(limit_, n)) + self.limit = limit + if isinstance(jitter, numbers.Number): + jitter_ = jitter + + def jitter(): + return jitter_ + self.jitter = jitter + + def __call__(self): + time.sleep(next(self)) + + def __next__(self): + delay = self.delay + self.bump() + return delay + + def __iter__(self): + return self + + def bump(self): + self.delay = self.limit(self.delay * self.factor + self.jitter()) + + def reset(self): + saved = self._saved___init__ + self.__init__(*saved.args, **saved.kwargs) diff --git a/lib/tempora/utc.py b/lib/tempora/utc.py new file mode 100644 index 00000000..fb60bbbe --- /dev/null +++ b/lib/tempora/utc.py @@ -0,0 +1,39 @@ +""" +Facilities for common time operations in UTC. + +Inspired by the `utc project `_. + +>>> dt = now() +>>> dt == fromtimestamp(dt.timestamp()) +True +>>> dt.tzinfo +datetime.timezone.utc + +>>> from time import time as timestamp +>>> now().timestamp() - timestamp() < 0.1 +True + +>>> (now() - fromtimestamp(timestamp())).total_seconds() < 0.1 +True + +>>> datetime(2018, 6, 26, 0).tzinfo +datetime.timezone.utc + +>>> time(0, 0).tzinfo +datetime.timezone.utc +""" + +import datetime as std +import functools + + +__all__ = ['now', 'fromtimestamp', 'datetime', 'time'] + + +now = functools.partial(std.datetime.now, std.timezone.utc) +fromtimestamp = functools.partial( + std.datetime.fromtimestamp, + tz=std.timezone.utc, +) +datetime = functools.partial(std.datetime, tzinfo=std.timezone.utc) +time = functools.partial(std.time, tzinfo=std.timezone.utc)