diff --git a/lib/tempora/__init__.py b/lib/tempora/__init__.py index afcb4441..224ba5a5 100644 --- a/lib/tempora/__init__.py +++ b/lib/tempora/__init__.py @@ -1,75 +1,79 @@ -# -*- 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 warnings +import contextlib -import six - -__metaclass__ = type +from jaraco.functools import once class Parser: - """ - Datetime parser: parses a date-time string using multiple possible - formats. + """ + *deprecated* - >>> 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) + Datetime parser: parses a date-time string using multiple possible + formats. - Be forewarned, a ValueError will be raised if more than one format - matches: + >>> 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) - >>> Parser(('%H%M', '%H%M%S')).parse('732') - Traceback (most recent call last): - ... - ValueError: More than one format string matched target 732. - """ + Be forewarned, a ValueError will be raised if more than one format + matches: - formats = ('%m/%d/%Y', '%m/%d/%y', '%Y-%m-%d', '%d-%b-%Y', '%d-%b-%y') - "some common default formats" + >>> Parser(('%H%M', '%H%M%S')).parse('732') + Traceback (most recent call last): + ... + ValueError: More than one format string matched target 732. - def __init__(self, formats=None): - if formats: - self.formats = formats + >>> Parser(('%H',)).parse('22:21') + Traceback (most recent call last): + ... + ValueError: No format strings matched the target 22:21. + """ - 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] + formats = ('%m/%d/%Y', '%m/%d/%y', '%Y-%m-%d', '%d-%b-%Y', '%d-%b-%y') + "some common default formats" - def _parse(self, format): - try: - result = time.strptime(self.target, format) - except ValueError: - result = False - return result + def __init__(self, formats=None): + warnings.warn("Use dateutil.parser", DeprecationWarning) + 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 @@ -94,413 +98,585 @@ seconds_per_month = seconds_per_year / 12 hours_per_month = hours_per_day * days_per_year / 12 +@once +def _needs_year_help(): + """ + Some versions of Python render %Y with only three characters :( + https://bugs.python.org/issue39103 + """ + return len(datetime.date(900, 1, 1).strftime('%Y')) != 4 + + +def ensure_datetime(ob): + """ + Given a datetime or date or time object from the ``datetime`` + module, always return a datetime using default values. + """ + if isinstance(ob, datetime.datetime): + return ob + date = time = ob + if isinstance(ob, datetime.date): + time = datetime.time() + if isinstance(ob, datetime.time): + date = datetime.date(1900, 1, 1) + return datetime.datetime.combine(date, time) + + 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)) - ) + """ + Portable strftime. - def doSub(s, sub): - return s.replace(*sub) + In the stdlib, strftime has `known portability problems + `_. This function + aims to smooth over those issues and provide a + consistent experience across the major platforms. - def doSubs(s): - return functools.reduce(doSub, subs, s) + >>> strftime('%Y', datetime.datetime(1890, 1, 1)) + '1890' + >>> strftime('%Y', datetime.datetime(900, 1, 1)) + '0900' - fmt = '%%'.join(map(doSubs, fmt.split('%%'))) - return t.strftime(fmt) + Supports time.struct_time, tuples, and datetime.datetime objects. + >>> strftime('%Y-%m-%d', (1976, 5, 7)) + '1976-05-07' -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]) + Also supports date objects + >>> strftime('%Y', datetime.date(1976, 5, 7)) + '1976' -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 + Also supports milliseconds using %s. - @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())) + >>> strftime('%s', datetime.time(microsecond=20000)) + '020' - @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) + Also supports microseconds (3 digits) using %µ - @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 + >>> strftime('%µ', datetime.time(microsecond=123456)) + '456' - @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__ + Historically, %u was used for microseconds, but now + it honors the value rendered by stdlib. - @staticmethod - def __dt_from_time_struct_time__(s): - return datetime.datetime(*s[:6]) + >>> strftime('%u', datetime.date(1976, 5, 7)) + '5' + + Also supports microseconds (6 digits) using %f + + >>> strftime('%f', datetime.time(microsecond=23456)) + '023456' + + Even supports time values on date objects (discouraged): + + >>> strftime('%f', datetime.date(1976, 1, 1)) + '000000' + >>> strftime('%µ', datetime.date(1976, 1, 1)) + '000' + >>> strftime('%s', datetime.date(1976, 1, 1)) + '000' + + And vice-versa: + + >>> strftime('%Y', datetime.time()) + '1900' + """ + if isinstance(t, (time.struct_time, tuple)): + t = datetime.datetime(*t[:6]) + t = ensure_datetime(t) + subs = ( + ('%s', '%03d' % (t.microsecond // 1000)), + ('%µ', '%03d' % (t.microsecond % 1000)), + ) + if _needs_year_help(): # pragma: nocover + subs += (('%Y', '%04d' % t.year),) + + 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 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. + """ + 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 + >>> 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 + # 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. + """ + 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 + >>> 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 + """ + Returns the nearest year to now inferred from a Julian date. + + >>> freezer = getfixture('freezer') + >>> freezer.move_to('2019-05-20') + >>> get_nearest_year_for_day(20) + 2019 + >>> get_nearest_year_for_day(340) + 2018 + >>> freezer.move_to('2019-12-15') + >>> get_nearest_year_for_day(20) + 2020 + """ + 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 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 + >>> 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 + """ + 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 + >>> 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, str): + 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, + """ + 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) + >>> 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, str) 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 + """ + 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) + >>> 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 + """ + warnings.warn("Use native division", DeprecationWarning) + return td / divisor 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())) + """ + >>> monkeypatch = getfixture('monkeypatch') + >>> import builtins + >>> monkeypatch.setattr(builtins, 'input', lambda prompt: '3/hour') + >>> calculate_prorated_values() + per minute: 0.05 + per hour: 3.0 + per day: 72.0 + per month: 2191.454166666667 + per year: 26297.45 + """ + rate = input("Enter the rate (3/hour, 50/month)> ") + for period, value in _prorated_values(rate): + print("per {period}: {value}".format(**locals())) + + +def _prorated_values(rate): + """ + Given a rate (a string in units per unit time), and return that same + rate for various time periods. + + >>> for period, value in _prorated_values('20/hour'): + ... print('{period}: {value:0.3f}'.format(**locals())) + minute: 0.333 + hour: 20.000 + day: 480.000 + month: 14609.694 + year: 175316.333 + + """ + 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) + yield period, period_value 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. + """ + 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') + datetime.timedelta(days=1) - >>> parse_timedelta('1 day, 30 seconds') - datetime.timedelta(days=1, seconds=30) + >>> 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) + >>> parse_timedelta('47.32 days, 20 minutes, 15.4 milliseconds') + datetime.timedelta(days=47, seconds=28848, microseconds=15400) - Supports weeks, months, years + Supports weeks, months, years - >>> parse_timedelta('1 week') - datetime.timedelta(days=7) + >>> parse_timedelta('1 week') + datetime.timedelta(days=7) - >>> parse_timedelta('1 year, 1 month') - datetime.timedelta(days=395, seconds=58685) + >>> parse_timedelta('1 year, 1 month') + datetime.timedelta(days=395, seconds=58685) - Note that months and years strict intervals, not aligned - to a calendar: + 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()) + >>> now = datetime.datetime.now() + >>> later = now + parse_timedelta('1 year') + >>> diff = later.replace(year=now.year) - now + >>> diff.seconds + 20940 + + >>> parse_timedelta('14 seconds foo') + Traceback (most recent call last): + ... + ValueError: Unexpected 'foo' + + Supports abbreviations: + + >>> parse_timedelta('1s') + datetime.timedelta(seconds=1) + + >>> parse_timedelta('1sec') + datetime.timedelta(seconds=1) + + >>> parse_timedelta('5min1sec') + datetime.timedelta(seconds=301) + + >>> parse_timedelta('1 ms') + datetime.timedelta(microseconds=1000) + + >>> parse_timedelta('1 µs') + datetime.timedelta(microseconds=1) + + >>> parse_timedelta('1 us') + datetime.timedelta(microseconds=1) + + And supports the common colon-separated duration: + + >>> parse_timedelta('14:00:35.362') + datetime.timedelta(seconds=50435, microseconds=362000) + + TODO: Should this be 14 hours or 14 minutes? + + >>> parse_timedelta('14:00') + datetime.timedelta(seconds=50400) + + >>> parse_timedelta('14:00 minutes') + Traceback (most recent call last): + ... + ValueError: Cannot specify units with composite delta + + Nanoseconds get rounded to the nearest microsecond: + + >>> parse_timedelta('600 ns') + datetime.timedelta(microseconds=1) + + >>> parse_timedelta('.002 µs, 499 ns') + datetime.timedelta(microseconds=1) + """ + return _parse_timedelta_nanos(str).resolve() -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 _parse_timedelta_nanos(str): + parts = re.finditer(r'(?P[\d.:]+)\s?(?P[^\W\d_]+)?', str) + chk_parts = _check_unmatched(parts, str) + deltas = map(_parse_timedelta_part, chk_parts) + return sum(deltas, _Saved_NS()) + + +def _check_unmatched(matches, text): + """ + Ensure no words appear in unmatched text. + """ + + def check_unmatched(unmatched): + found = re.search(r'\w+', unmatched) + if found: + raise ValueError(f"Unexpected {found.group(0)!r}") + + pos = 0 + for match in matches: + check_unmatched(text[pos : match.start()]) + yield match + pos = match.end() + check_unmatched(text[match.end() :]) + + +_unit_lookup = { + 'µs': 'microsecond', + 'µsec': 'microsecond', + 'us': 'microsecond', + 'usec': 'microsecond', + 'micros': 'microsecond', + 'ms': 'millisecond', + 'msec': 'millisecond', + 'millis': 'millisecond', + 's': 'second', + 'sec': 'second', + 'h': 'hour', + 'hr': 'hour', + 'm': 'minute', + 'min': 'minute', + 'w': 'week', + 'wk': 'week', + 'd': 'day', + 'ns': 'nanosecond', + 'nsec': 'nanosecond', + 'nanos': 'nanosecond', +} + + +def _resolve_unit(raw_match): + if raw_match is None: + return 'second' + text = raw_match.lower() + return _unit_lookup.get(text, text) + + +def _parse_timedelta_composite(raw_value, unit): + if unit != 'seconds': + raise ValueError("Cannot specify units with composite delta") + values = raw_value.split(':') + units = 'hours', 'minutes', 'seconds' + composed = ' '.join(f'{value} {unit}' for value, unit in zip(values, units)) + return _parse_timedelta_nanos(composed) + + +def _parse_timedelta_part(match): + unit = _resolve_unit(match.group('unit')) + if not unit.endswith('s'): + unit += 's' + raw_value = match.group('value') + if ':' in raw_value: + return _parse_timedelta_composite(raw_value, unit) + value = float(raw_value) + if unit == 'months': + unit = 'years' + value = value / 12 + if unit == 'years': + unit = 'days' + value = value * days_per_year + return _Saved_NS.derive(unit, value) + + +class _Saved_NS: + """ + Bundle a timedelta with nanoseconds. + + >>> _Saved_NS.derive('microseconds', .001) + _Saved_NS(td=datetime.timedelta(0), nanoseconds=1) + """ + + td = datetime.timedelta() + nanoseconds = 0 + multiplier = dict( + seconds=1000000000, + milliseconds=1000000, + microseconds=1000, + ) + + def __init__(self, **kwargs): + vars(self).update(kwargs) + + @classmethod + def derive(cls, unit, value): + if unit == 'nanoseconds': + return _Saved_NS(nanoseconds=value) + + res = _Saved_NS(td=datetime.timedelta(**{unit: value})) + with contextlib.suppress(KeyError): + res.nanoseconds = int(value * cls.multiplier[unit]) % 1000 + return res + + def __add__(self, other): + return _Saved_NS( + td=self.td + other.td, nanoseconds=self.nanoseconds + other.nanoseconds + ) + + def resolve(self): + """ + Resolve any nanoseconds into the microseconds field, + discarding any nanosecond resolution (but honoring partial + microseconds). + """ + addl_micros = round(self.nanoseconds / 1000) + return self.td + datetime.timedelta(microseconds=addl_micros) + + def __repr__(self): + return f'_Saved_NS(td={self.td!r}, nanoseconds={self.nanoseconds!r})' def divide_timedelta(td1, td2): - """ - Get the ratio of two timedeltas + """ + 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() + >>> one_day = datetime.timedelta(days=1) + >>> one_hour = datetime.timedelta(hours=1) + >>> divide_timedelta(one_hour, one_day) == 1 / 24 + True + """ + warnings.warn("Use native division", DeprecationWarning) + return td1 / td2 def date_range(start=None, stop=None, step=None): - """ - Much like the built-in function range, but works with dates + """ + 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 + >>> 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 + >>> from_now = date_range(stop=datetime.datetime(2099, 12, 31)) + >>> next(from_now) + datetime.datetime(...) + """ + 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 index 1ad093b2..a94c9819 100644 --- a/lib/tempora/schedule.py +++ b/lib/tempora/schedule.py @@ -1,10 +1,17 @@ -# -*- coding: utf-8 -*- - -""" -Classes for calling functions a schedule. """ +Classes for calling functions a schedule. Has time zone support. -from __future__ import absolute_import +For example, to run a job at 08:00 every morning in 'Asia/Calcutta': + +>>> job = lambda: print("time is now", datetime.datetime()) +>>> time = datetime.time(8, tzinfo=pytz.timezone('Asia/Calcutta')) +>>> cmd = PeriodicCommandFixedDelay.daily_at(time, job) +>>> sched = InvokeScheduler() +>>> sched.add(cmd) +>>> while True: # doctest: +SKIP +... sched.run_pending() +... time.sleep(.1) +""" import datetime import numbers @@ -13,8 +20,6 @@ import bisect import pytz -__metaclass__ = type - def now(): """ @@ -44,8 +49,13 @@ class DelayedCommand(datetime.datetime): @classmethod def from_datetime(cls, other): return cls( - other.year, other.month, other.day, other.hour, - other.minute, other.second, other.microsecond, + other.year, + other.month, + other.day, + other.hour, + other.minute, + other.second, + other.microsecond, other.tzinfo, ) @@ -91,6 +101,7 @@ 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 @@ -117,8 +128,7 @@ class PeriodicCommand(DelayedCommand): def __setattr__(self, key, value): if key == 'delay' and not value > datetime.timedelta(): raise ValueError( - "A PeriodicCommand must have a positive, " - "non-zero delay." + "A PeriodicCommand must have a positive, " "non-zero delay." ) super(PeriodicCommand, self).__setattr__(key, value) @@ -132,6 +142,11 @@ class PeriodicCommandFixedDelay(PeriodicCommand): @classmethod def at_time(cls, at, delay, target): + """ + >>> cmd = PeriodicCommandFixedDelay.at_time(0, 30, None) + >>> cmd.delay.total_seconds() + 30.0 + """ at = cls._from_timestamp(at) cmd = cls.from_datetime(at) if isinstance(delay, numbers.Number): @@ -144,11 +159,18 @@ class PeriodicCommandFixedDelay(PeriodicCommand): def daily_at(cls, at, target): """ Schedule a command to run at a specific time each day. + + >>> from tempora import utc + >>> noon = utc.time(12, 0) + >>> cmd = PeriodicCommandFixedDelay.daily_at(noon, None) + >>> cmd.delay.total_seconds() + 86400.0 """ 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 + while when < now(): when += daily return cls.at_time(cls._localize(when), daily, target) @@ -158,6 +180,7 @@ class Scheduler: A rudimentary abstract scheduler accepting DelayedCommands and dispatching them on schedule. """ + def __init__(self): self.queue = [] @@ -186,6 +209,7 @@ class InvokeScheduler(Scheduler): """ Command targets are functions to be invoked on schedule. """ + def run(self, command): command.target() @@ -194,8 +218,9 @@ class CallbackScheduler(Scheduler): """ Command targets are passed to a dispatch callable on schedule. """ + def __init__(self, dispatch): - super(CallbackScheduler, self).__init__() + super().__init__() self.dispatch = dispatch def run(self, command): diff --git a/lib/tempora/tests/test_schedule.py b/lib/tempora/tests/test_schedule.py index 38eb8dc9..0ce35435 100644 --- a/lib/tempora/tests/test_schedule.py +++ b/lib/tempora/tests/test_schedule.py @@ -1,6 +1,7 @@ import time import random import datetime +from unittest import mock import pytest import pytz @@ -8,111 +9,141 @@ 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) + """ + 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__ + "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 + """ + 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_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 + 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 + + @pytest.mark.parametrize("hour", range(10, 14)) + @pytest.mark.parametrize("tz_offset", (14, -14)) + def test_command_at_noon_distant_local(self, hour, tz_offset): + """ + Run test_command_at_noon, but with the local timezone + more than 12 hours away from UTC. + """ + with freezegun.freeze_time(f"2020-01-10 {hour:02}:01", tz_offset=tz_offset): + self.test_command_at_noon() 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_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_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 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) + 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) + 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) + + +class TestScheduler: + def test_invoke_scheduler(self): + sched = schedule.InvokeScheduler() + target = mock.MagicMock() + cmd = schedule.DelayedCommand.after(0, target) + sched.add(cmd) + sched.run_pending() + target.assert_called_once() + assert not sched.queue + + def test_callback_scheduler(self): + callback = mock.MagicMock() + sched = schedule.CallbackScheduler(callback) + target = mock.MagicMock() + cmd = schedule.DelayedCommand.after(0, target) + sched.add(cmd) + sched.run_pending() + callback.assert_called_once_with(target) + + def test_periodic_command(self): + sched = schedule.InvokeScheduler() + target = mock.MagicMock() + + before = datetime.datetime.utcnow() + + cmd = schedule.PeriodicCommand.after(10, target) + sched.add(cmd) + sched.run_pending() + target.assert_not_called() + + with freezegun.freeze_time(before + datetime.timedelta(seconds=15)): + sched.run_pending() + assert sched.queue + target.assert_called_once() + + with freezegun.freeze_time(before + datetime.timedelta(seconds=25)): + sched.run_pending() + assert target.call_count == 2 diff --git a/lib/tempora/tests/test_timing.py b/lib/tempora/tests/test_timing.py new file mode 100644 index 00000000..43bf7efc --- /dev/null +++ b/lib/tempora/tests/test_timing.py @@ -0,0 +1,50 @@ +import datetime +import time +import contextlib +import os +from unittest import mock + +import pytest +from tempora import timing + + +def test_IntervalGovernor(): + """ + IntervalGovernor should prevent a function from being called more than + once per interval. + """ + func_under_test = mock.MagicMock() + # to look like a function, it needs a __name__ attribute + func_under_test.__name__ = 'func_under_test' + interval = datetime.timedelta(seconds=1) + governed = timing.IntervalGovernor(interval)(func_under_test) + governed('a') + governed('b') + governed(3, 'sir') + func_under_test.assert_called_once_with('a') + + +@pytest.fixture +def alt_tz(monkeypatch): + hasattr(time, 'tzset') or pytest.skip("tzset not available") + + @contextlib.contextmanager + def change(): + val = 'AEST-10AEDT-11,M10.5.0,M3.5.0' + with monkeypatch.context() as ctx: + ctx.setitem(os.environ, 'TZ', val) + time.tzset() + yield + time.tzset() + + return change() + + +def test_Stopwatch_timezone_change(alt_tz): + """ + The stopwatch should provide a consistent duration even + if the timezone changes. + """ + watch = timing.Stopwatch() + with alt_tz: + assert abs(watch.split().total_seconds()) < 0.1 diff --git a/lib/tempora/timing.py b/lib/tempora/timing.py index 2835ddc2..6b3147a9 100644 --- a/lib/tempora/timing.py +++ b/lib/tempora/timing.py @@ -1,258 +1,266 @@ -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals, absolute_import - import datetime import functools import numbers import time - -import six +import collections.abc +import contextlib import jaraco.functools -__metaclass__ = type - - class Stopwatch: - """ - A simple stopwatch which starts automatically. + """ + 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 + >>> 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: + It should be possible to launch the Stopwatch in a context: - >>> with Stopwatch() as watch: - ... assert isinstance(watch.split(), datetime.timedelta) + >>> 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:: + 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() + >>> watch.elapsed + datetime.timedelta(...) + >>> watch.elapsed.seconds + 0 + """ - def reset(self): - self.elapsed = datetime.timedelta(0) - if hasattr(self, 'start_time'): - del self.start_time + def __init__(self): + self.reset() + self.start() - def start(self): - self.start_time = datetime.datetime.utcnow() + def reset(self): + self.elapsed = datetime.timedelta(0) + with contextlib.suppress(AttributeError): + del self.start_time - def stop(self): - stop_time = datetime.datetime.utcnow() - self.elapsed += stop_time - self.start_time - del self.start_time - return self.elapsed + def start(self): + self.start_time = datetime.datetime.utcnow() - def split(self): - local_duration = datetime.datetime.utcnow() - self.start_time - return self.elapsed + local_duration + def stop(self): + stop_time = datetime.datetime.utcnow() + self.elapsed += stop_time - self.start_time + del self.start_time + return self.elapsed - # context manager support - def __enter__(self): - self.start() - return self + def split(self): + local_duration = datetime.datetime.utcnow() - self.start_time + return self.elapsed + local_duration - def __exit__(self, exc_type, exc_value, traceback): - self.stop() + # 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 + """ + Decorate a function to only allow it to be called once per + min_interval. Otherwise, it returns 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 + >>> gov = IntervalGovernor(30) + >>> gov.min_interval.total_seconds() + 30.0 + """ - __call__ = decorate + 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. + """ + 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__() + >>> t = Timer(0.1) + >>> t.expired() + False + >>> __import__('time').sleep(0.15) + >>> t.expired() + True + """ - def _accept(self, target): - "Accept None or ∞ or datetime or numeric for target" - if isinstance(target, datetime.timedelta): - target = target.total_seconds() + def __init__(self, target=float('Inf')): + self.target = self._accept(target) + super(Timer, self).__init__() - if target is None: - # treat None as infinite target - target = float('Inf') + @staticmethod + def _accept(target): + """ + Accept None or ∞ or datetime or numeric for target - return target + >>> Timer._accept(datetime.timedelta(seconds=30)) + 30.0 + >>> Timer._accept(None) + inf + """ + if isinstance(target, datetime.timedelta): + target = target.total_seconds() - def expired(self): - return self.split().total_seconds() > self.target + 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. +class BackoffDelay(collections.abc.Iterator): + """ + Exponential backoff delay. - Useful for defining delays between retries. Consider for use - with ``jaraco.functools.retry_call`` as the cleanup. + 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. + Default behavior has no effect; a delay or jitter must + be supplied for the call to be non-degenerate. - >>> bd = BackoffDelay() - >>> bd() - >>> bd() + >>> bd = BackoffDelay() + >>> bd() + >>> bd() - The following instance will delay 10ms for the first call, - 20ms for the second, etc. + The following instance will delay 10ms for the first call, + 20ms for the second, etc. - >>> bd = BackoffDelay(delay=0.01, factor=2) - >>> bd() - >>> bd() + >>> bd = BackoffDelay(delay=0.01, factor=2) + >>> bd() + >>> bd() - Inspect and adjust the state of the delay anytime. + Inspect and adjust the state of the delay anytime. - >>> bd.delay - 0.04 - >>> bd.delay = 0.01 + >>> bd.delay + 0.04 + >>> bd.delay = 0.01 - Set limit to prevent the delay from exceeding bounds. + Set limit to prevent the delay from exceeding bounds. - >>> bd = BackoffDelay(delay=0.01, factor=2, limit=0.015) - >>> bd() - >>> bd.delay - 0.015 + >>> bd = BackoffDelay(delay=0.01, factor=2, limit=0.015) + >>> bd() + >>> bd.delay + 0.015 - To reset the backoff, simply call ``.reset()``: + To reset the backoff, simply call ``.reset()``: - >>> bd.reset() - >>> bd.delay - 0.01 + >>> bd.reset() + >>> bd.delay + 0.01 - Iterate on the object to retrieve/advance the delay values. + 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) + >>> 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. + 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 + >>> 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. + Pass a jitter to add or subtract seconds to the delay. - >>> bd = BackoffDelay(jitter=0.01) - >>> next(bd) - 0 - >>> next(bd) - 0.01 + >>> 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: + 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 - """ + >>> 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 + delay = 0 - factor = 1 - "Multiplier applied to delay" + factor = 1 + "Multiplier applied to delay" - jitter = 0 - "Number or callable returning extra seconds to add 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 + @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 limit(n): + return max(0, min(limit_, n)) - def jitter(): - return jitter_ - self.jitter = jitter + self.limit = limit + if isinstance(jitter, numbers.Number): + jitter_ = jitter - def __call__(self): - time.sleep(next(self)) + def jitter(): + return jitter_ - def __next__(self): - delay = self.delay - self.bump() - return delay + self.jitter = jitter - def __iter__(self): - return self + def __call__(self): + time.sleep(next(self)) - def bump(self): - self.delay = self.limit(self.delay * self.factor + self.jitter()) + def __next__(self): + delay = self.delay + self.bump() + return delay - def reset(self): - saved = self._saved___init__ - self.__init__(*saved.args, **saved.kwargs) + 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 index fb60bbbe..a585fb54 100644 --- a/lib/tempora/utc.py +++ b/lib/tempora/utc.py @@ -31,9 +31,6 @@ __all__ = ['now', 'fromtimestamp', 'datetime', 'time'] now = functools.partial(std.datetime.now, std.timezone.utc) -fromtimestamp = functools.partial( - std.datetime.fromtimestamp, - tz=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)