# -*- coding: utf-8 -*- # Copyright (c) 2013, Mahmoud Hashemi # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # * The names of the contributors may not be used to endorse or # promote products derived from this software without specific # prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Python's :mod:`datetime` module provides some of the most complex and powerful primitives in the Python standard library. Time is nontrivial, but thankfully its support is first-class in Python. ``dateutils`` provides some additional tools for working with time. Additionally, timeutils provides a few basic utilities for working with timezones in Python. The Python :mod:`datetime` module's documentation describes how to create a :class:`~datetime.datetime`-compatible :class:`~datetime.tzinfo` subtype. It even provides a few examples. The following module defines usable forms of the timezones in those docs, as well as a couple other useful ones, :data:`UTC` (aka GMT) and :data:`LocalTZ` (representing the local timezone as configured in the operating system). For timezones beyond these, as well as a higher degree of accuracy in corner cases, check out `pytz`_ and `dateutil`_. .. _pytz: https://pypi.python.org/pypi/pytz .. _dateutil: https://dateutil.readthedocs.io/en/stable/index.html """ import re import time import bisect import operator from datetime import tzinfo, timedelta, date, datetime def total_seconds(td): """For those with older versions of Python, a pure-Python implementation of Python 2.7's :meth:`~datetime.timedelta.total_seconds`. Args: td (datetime.timedelta): The timedelta to convert to seconds. Returns: float: total number of seconds >>> td = timedelta(days=4, seconds=33) >>> total_seconds(td) 345633.0 """ a_milli = 1000000.0 td_ds = td.seconds + (td.days * 86400) # 24 * 60 * 60 td_micro = td.microseconds + (td_ds * a_milli) return td_micro / a_milli def dt_to_timestamp(dt): """Converts from a :class:`~datetime.datetime` object to an integer timestamp, suitable interoperation with :func:`time.time` and other `Epoch-based timestamps`. .. _Epoch-based timestamps: https://en.wikipedia.org/wiki/Unix_time >>> abs(round(time.time() - dt_to_timestamp(datetime.utcnow()), 2)) 0.0 ``dt_to_timestamp`` supports both timezone-aware and naïve :class:`~datetime.datetime` objects. Note that it assumes naïve datetime objects are implied UTC, such as those generated with :meth:`datetime.datetime.utcnow`. If your datetime objects are local time, such as those generated with :meth:`datetime.datetime.now`, first convert it using the :meth:`datetime.datetime.replace` method with ``tzinfo=`` :class:`LocalTZ` object in this module, then pass the result of that to ``dt_to_timestamp``. """ if dt.tzinfo: td = dt - EPOCH_AWARE else: td = dt - EPOCH_NAIVE return total_seconds(td) _NONDIGIT_RE = re.compile(r'\D') def isoparse(iso_str): """Parses the limited subset of `ISO8601-formatted time`_ strings as returned by :meth:`datetime.datetime.isoformat`. >>> epoch_dt = datetime.utcfromtimestamp(0) >>> iso_str = epoch_dt.isoformat() >>> print(iso_str) 1970-01-01T00:00:00 >>> isoparse(iso_str) datetime.datetime(1970, 1, 1, 0, 0) >>> utcnow = datetime.utcnow() >>> utcnow == isoparse(utcnow.isoformat()) True For further datetime parsing, see the `iso8601`_ package for strict ISO parsing and `dateutil`_ package for loose parsing and more. .. _ISO8601-formatted time: https://en.wikipedia.org/wiki/ISO_8601 .. _iso8601: https://pypi.python.org/pypi/iso8601 .. _dateutil: https://pypi.python.org/pypi/python-dateutil """ dt_args = [int(p) for p in _NONDIGIT_RE.split(iso_str)] return datetime(*dt_args) _BOUNDS = [(0, timedelta(seconds=1), 'second'), (1, timedelta(seconds=60), 'minute'), (1, timedelta(seconds=3600), 'hour'), (1, timedelta(days=1), 'day'), (1, timedelta(days=7), 'week'), (2, timedelta(days=30), 'month'), (1, timedelta(days=365), 'year')] _BOUNDS = [(b[0] * b[1], b[1], b[2]) for b in _BOUNDS] _BOUND_DELTAS = [b[0] for b in _BOUNDS] _FLOAT_PATTERN = r'[+-]?\ *(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?' _PARSE_TD_RE = re.compile(r"((?P%s)\s*(?P\w)\w*)" % _FLOAT_PATTERN) _PARSE_TD_KW_MAP = dict([(unit[0], unit + 's') for _, _, unit in reversed(_BOUNDS[:-2])]) def parse_timedelta(text): """Robustly parses a short text description of a time period into a :class:`datetime.timedelta`. Supports weeks, days, hours, minutes, and seconds, with or without decimal points: Args: text (str): Text to parse. Returns: datetime.timedelta Raises: ValueError: on parse failure. >>> parse_td('1d 2h 3.5m 0s') == timedelta(days=1, seconds=7410) True Also supports full words and whitespace. >>> parse_td('2 weeks 1 day') == timedelta(days=15) True Negative times are supported, too: >>> parse_td('-1.5 weeks 3m 20s') == timedelta(days=-11, seconds=43400) True """ td_kwargs = {} for match in _PARSE_TD_RE.finditer(text): value, unit = match.group('value'), match.group('unit') try: unit_key = _PARSE_TD_KW_MAP[unit] except KeyError: raise ValueError('invalid time unit %r, expected one of %r' % (unit, _PARSE_TD_KW_MAP.keys())) try: value = float(value) except ValueError: raise ValueError('invalid time value for unit %r: %r' % (unit, value)) td_kwargs[unit_key] = value return timedelta(**td_kwargs) parse_td = parse_timedelta # legacy alias def _cardinalize_time_unit(unit, value): # removes dependency on strutils; nice and simple because # all time units cardinalize normally if value == 1: return unit return unit + 's' def decimal_relative_time(d, other=None, ndigits=0, cardinalize=True): """Get a tuple representing the relative time difference between two :class:`~datetime.datetime` objects or one :class:`~datetime.datetime` and now. Args: d (datetime): The first datetime object. other (datetime): An optional second datetime object. If unset, defaults to the current time as determined :meth:`datetime.utcnow`. ndigits (int): The number of decimal digits to round to, defaults to ``0``. cardinalize (bool): Whether to pluralize the time unit if appropriate, defaults to ``True``. Returns: (float, str): A tuple of the :class:`float` difference and respective unit of time, pluralized if appropriate and *cardinalize* is set to ``True``. Unlike :func:`relative_time`, this method's return is amenable to localization into other languages and custom phrasing and formatting. >>> now = datetime.utcnow() >>> decimal_relative_time(now - timedelta(days=1, seconds=3600), now) (1.0, 'day') >>> decimal_relative_time(now - timedelta(seconds=0.002), now, ndigits=5) (0.002, 'seconds') >>> decimal_relative_time(now, now - timedelta(days=900), ndigits=1) (-2.5, 'years') """ if other is None: other = datetime.utcnow() diff = other - d diff_seconds = total_seconds(diff) abs_diff = abs(diff) b_idx = bisect.bisect(_BOUND_DELTAS, abs_diff) - 1 bbound, bunit, bname = _BOUNDS[b_idx] f_diff = diff_seconds / total_seconds(bunit) rounded_diff = round(f_diff, ndigits) if cardinalize: return rounded_diff, _cardinalize_time_unit(bname, abs(rounded_diff)) return rounded_diff, bname def relative_time(d, other=None, ndigits=0): """Get a string representation of the difference between two :class:`~datetime.datetime` objects or one :class:`~datetime.datetime` and the current time. Handles past and future times. Args: d (datetime): The first datetime object. other (datetime): An optional second datetime object. If unset, defaults to the current time as determined :meth:`datetime.utcnow`. ndigits (int): The number of decimal digits to round to, defaults to ``0``. Returns: A short English-language string. >>> now = datetime.utcnow() >>> relative_time(now, ndigits=1) '0 seconds ago' >>> relative_time(now - timedelta(days=1, seconds=36000), ndigits=1) '1.4 days ago' >>> relative_time(now + timedelta(days=7), now, ndigits=1) '1 week from now' """ drt, unit = decimal_relative_time(d, other, ndigits, cardinalize=True) phrase = 'ago' if drt < 0: phrase = 'from now' return '%g %s %s' % (abs(drt), unit, phrase) def strpdate(string, format): """Parse the date string according to the format in `format`. Returns a :class:`date` object. Internally, :meth:`datetime.strptime` is used to parse the string and thus conversion specifiers for time fields (e.g. `%H`) may be provided; these will be parsed but ignored. Args: string (str): The date string to be parsed. format (str): The `strptime`_-style date format string. Returns: datetime.date .. _`strptime`: https://docs.python.org/2/library/datetime.html#strftime-strptime-behavior >>> strpdate('2016-02-14', '%Y-%m-%d') datetime.date(2016, 2, 14) >>> strpdate('26/12 (2015)', '%d/%m (%Y)') datetime.date(2015, 12, 26) >>> strpdate('20151231 23:59:59', '%Y%m%d %H:%M:%S') datetime.date(2015, 12, 31) >>> strpdate('20160101 00:00:00.001', '%Y%m%d %H:%M:%S.%f') datetime.date(2016, 1, 1) """ whence = datetime.strptime(string, format) return whence.date() def daterange(start, stop, step=1, inclusive=False): """In the spirit of :func:`range` and :func:`xrange`, the `daterange` generator that yields a sequence of :class:`~datetime.date` objects, starting at *start*, incrementing by *step*, until *stop* is reached. When *inclusive* is True, the final date may be *stop*, **if** *step* falls evenly on it. By default, *step* is one day. See details below for many more details. Args: start (datetime.date): The starting date The first value in the sequence. stop (datetime.date): The stopping date. By default not included in return. Can be `None` to yield an infinite sequence. step (int): The value to increment *start* by to reach *stop*. Can be an :class:`int` number of days, a :class:`datetime.timedelta`, or a :class:`tuple` of integers, `(year, month, day)`. Positive and negative *step* values are supported. inclusive (bool): Whether or not the *stop* date can be returned. *stop* is only returned when a *step* falls evenly on it. >>> christmas = date(year=2015, month=12, day=25) >>> boxing_day = date(year=2015, month=12, day=26) >>> new_year = date(year=2016, month=1, day=1) >>> for day in daterange(christmas, new_year): ... print(repr(day)) datetime.date(2015, 12, 25) datetime.date(2015, 12, 26) datetime.date(2015, 12, 27) datetime.date(2015, 12, 28) datetime.date(2015, 12, 29) datetime.date(2015, 12, 30) datetime.date(2015, 12, 31) >>> for day in daterange(christmas, boxing_day): ... print(repr(day)) datetime.date(2015, 12, 25) >>> for day in daterange(date(2017, 5, 1), date(2017, 8, 1), ... step=(0, 1, 0), inclusive=True): ... print(repr(day)) datetime.date(2017, 5, 1) datetime.date(2017, 6, 1) datetime.date(2017, 7, 1) datetime.date(2017, 8, 1) *Be careful when using stop=None, as this will yield an infinite sequence of dates.* """ if not isinstance(start, date): raise TypeError("start expected datetime.date instance") if stop and not isinstance(stop, date): raise TypeError("stop expected datetime.date instance or None") try: y_step, m_step, d_step = step except TypeError: y_step, m_step, d_step = 0, 0, step else: y_step, m_step = int(y_step), int(m_step) if isinstance(d_step, int): d_step = timedelta(days=int(d_step)) elif isinstance(d_step, timedelta): pass else: raise ValueError('step expected int, timedelta, or tuple' ' (year, month, day), not: %r' % step) m_step += y_step * 12 if stop is None: finished = lambda now, stop: False elif start <= stop: finished = operator.gt if inclusive else operator.ge else: finished = operator.lt if inclusive else operator.le now = start while not finished(now, stop): yield now if m_step: m_y_step, cur_month = divmod((now.month - 1) + m_step, 12) now = now.replace(year=now.year + m_y_step, month=(cur_month + 1)) now = now + d_step return # Timezone support (brought in from tzutils) ZERO = timedelta(0) HOUR = timedelta(hours=1) class ConstantTZInfo(tzinfo): """ A :class:`~datetime.tzinfo` subtype whose *offset* remains constant (no daylight savings). Args: name (str): Name of the timezone. offset (datetime.timedelta): Offset of the timezone. """ def __init__(self, name="ConstantTZ", offset=ZERO): self.name = name self.offset = offset @property def utcoffset_hours(self): return total_seconds(self.offset) / (60 * 60) def utcoffset(self, dt): return self.offset def tzname(self, dt): return self.name def dst(self, dt): return ZERO def __repr__(self): cn = self.__class__.__name__ return '%s(name=%r, offset=%r)' % (cn, self.name, self.offset) UTC = ConstantTZInfo('UTC') EPOCH_AWARE = datetime.fromtimestamp(0, UTC) EPOCH_NAIVE = datetime.utcfromtimestamp(0) class LocalTZInfo(tzinfo): """The ``LocalTZInfo`` type takes data available in the time module about the local timezone and makes a practical :class:`datetime.tzinfo` to represent the timezone settings of the operating system. For a more in-depth integration with the operating system, check out `tzlocal`_. It builds on `pytz`_ and implements heuristics for many versions of major operating systems to provide the official ``pytz`` tzinfo, instead of the LocalTZ generalization. .. _tzlocal: https://pypi.python.org/pypi/tzlocal .. _pytz: https://pypi.python.org/pypi/pytz """ _std_offset = timedelta(seconds=-time.timezone) _dst_offset = _std_offset if time.daylight: _dst_offset = timedelta(seconds=-time.altzone) def is_dst(self, dt): dt_t = (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.weekday(), 0, -1) local_t = time.localtime(time.mktime(dt_t)) return local_t.tm_isdst > 0 def utcoffset(self, dt): if self.is_dst(dt): return self._dst_offset return self._std_offset def dst(self, dt): if self.is_dst(dt): return self._dst_offset - self._std_offset return ZERO def tzname(self, dt): return time.tzname[self.is_dst(dt)] def __repr__(self): return '%s()' % self.__class__.__name__ LocalTZ = LocalTZInfo() def _first_sunday_on_or_after(dt): days_to_go = 6 - dt.weekday() if days_to_go: dt += timedelta(days_to_go) return dt # US DST Rules # # This is a simplified (i.e., wrong for a few cases) set of rules for US # DST start and end times. For a complete and up-to-date set of DST rules # and timezone definitions, visit the Olson Database (or try pytz): # http://www.twinsun.com/tz/tz-link.htm # http://sourceforge.net/projects/pytz/ (might not be up-to-date) # # In the US, since 2007, DST starts at 2am (standard time) on the second # Sunday in March, which is the first Sunday on or after Mar 8. DSTSTART_2007 = datetime(1, 3, 8, 2) # and ends at 2am (DST time; 1am standard time) on the first Sunday of Nov. DSTEND_2007 = datetime(1, 11, 1, 1) # From 1987 to 2006, DST used to start at 2am (standard time) on the first # Sunday in April and to end at 2am (DST time; 1am standard time) on the last # Sunday of October, which is the first Sunday on or after Oct 25. DSTSTART_1987_2006 = datetime(1, 4, 1, 2) DSTEND_1987_2006 = datetime(1, 10, 25, 1) # From 1967 to 1986, DST used to start at 2am (standard time) on the last # Sunday in April (the one on or after April 24) and to end at 2am (DST time; # 1am standard time) on the last Sunday of October, which is the first Sunday # on or after Oct 25. DSTSTART_1967_1986 = datetime(1, 4, 24, 2) DSTEND_1967_1986 = DSTEND_1987_2006 class USTimeZone(tzinfo): """Copied directly from the Python docs, the ``USTimeZone`` is a :class:`datetime.tzinfo` subtype used to create the :data:`Eastern`, :data:`Central`, :data:`Mountain`, and :data:`Pacific` tzinfo types. """ def __init__(self, hours, reprname, stdname, dstname): self.stdoffset = timedelta(hours=hours) self.reprname = reprname self.stdname = stdname self.dstname = dstname def __repr__(self): return self.reprname def tzname(self, dt): if self.dst(dt): return self.dstname else: return self.stdname def utcoffset(self, dt): return self.stdoffset + self.dst(dt) def dst(self, dt): if dt is None or dt.tzinfo is None: # An exception may be sensible here, in one or both cases. # It depends on how you want to treat them. The default # fromutc() implementation (called by the default astimezone() # implementation) passes a datetime with dt.tzinfo is self. return ZERO assert dt.tzinfo is self # Find start and end times for US DST. For years before 1967, return # ZERO for no DST. if 2006 < dt.year: dststart, dstend = DSTSTART_2007, DSTEND_2007 elif 1986 < dt.year < 2007: dststart, dstend = DSTSTART_1987_2006, DSTEND_1987_2006 elif 1966 < dt.year < 1987: dststart, dstend = DSTSTART_1967_1986, DSTEND_1967_1986 else: return ZERO start = _first_sunday_on_or_after(dststart.replace(year=dt.year)) end = _first_sunday_on_or_after(dstend.replace(year=dt.year)) # Can't compare naive to aware objects, so strip the timezone # from dt first. if start <= dt.replace(tzinfo=None) < end: return HOUR else: return ZERO Eastern = USTimeZone(-5, "Eastern", "EST", "EDT") Central = USTimeZone(-6, "Central", "CST", "CDT") Mountain = USTimeZone(-7, "Mountain", "MST", "MDT") Pacific = USTimeZone(-8, "Pacific", "PST", "PDT")