# Copyright 2010 Matt Chaput. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. 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. # # THIS SOFTWARE IS PROVIDED BY MATT CHAPUT ``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 MATT CHAPUT 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. # # The views and conclusions contained in the software and documentation are # those of the authors and should not be interpreted as representing official # policies, either expressed or implied, of Matt Chaput. import calendar import copy from datetime import date, datetime, timedelta from whoosh.compat import iteritems class TimeError(Exception): pass def relative_days(current_wday, wday, dir): """Returns the number of days (positive or negative) to the "next" or "last" of a certain weekday. ``current_wday`` and ``wday`` are numbers, i.e. 0 = monday, 1 = tuesday, 2 = wednesday, etc. >>> # Get the number of days to the next tuesday, if today is Sunday >>> relative_days(6, 1, 1) 2 :param current_wday: the number of the current weekday. :param wday: the target weekday. :param dir: -1 for the "last" (past) weekday, 1 for the "next" (future) weekday. """ if current_wday == wday: return 7 * dir if dir == 1: return (wday + 7 - current_wday) % 7 else: return (current_wday + 7 - wday) % 7 * -1 def timedelta_to_usecs(td): total = td.days * 86400000000 # Microseconds in a day total += td.seconds * 1000000 # Microseconds in a second total += td.microseconds return total def datetime_to_long(dt): """Converts a datetime object to a long integer representing the number of microseconds since ``datetime.min``. """ return timedelta_to_usecs(dt.replace(tzinfo=None) - dt.min) def long_to_datetime(x): """Converts a long integer representing the number of microseconds since ``datetime.min`` to a datetime object. """ days = x // 86400000000 # Microseconds in a day x -= days * 86400000000 seconds = x // 1000000 # Microseconds in a second x -= seconds * 1000000 return datetime.min + timedelta(days=days, seconds=seconds, microseconds=x) # Ambiguous datetime object class adatetime(object): """An "ambiguous" datetime object. This object acts like a ``datetime.datetime`` object but can have any of its attributes set to None, meaning unspecified. """ units = frozenset(("year", "month", "day", "hour", "minute", "second", "microsecond")) def __init__(self, year=None, month=None, day=None, hour=None, minute=None, second=None, microsecond=None): if isinstance(year, datetime): dt = year self.year, self.month, self.day = dt.year, dt.month, dt.day self.hour, self.minute, self.second = dt.hour, dt.minute, dt.second self.microsecond = dt.microsecond else: if month is not None and (month < 1 or month > 12): raise TimeError("month must be in 1..12") if day is not None and day < 1: raise TimeError("day must be greater than 1") if (year is not None and month is not None and day is not None and day > calendar.monthrange(year, month)[1]): raise TimeError("day is out of range for month") if hour is not None and (hour < 0 or hour > 23): raise TimeError("hour must be in 0..23") if minute is not None and (minute < 0 or minute > 59): raise TimeError("minute must be in 0..59") if second is not None and (second < 0 or second > 59): raise TimeError("second must be in 0..59") if microsecond is not None and (microsecond < 0 or microsecond > 999999): raise TimeError("microsecond must be in 0..999999") self.year, self.month, self.day = year, month, day self.hour, self.minute, self.second = hour, minute, second self.microsecond = microsecond def __eq__(self, other): if not other.__class__ is self.__class__: if not is_ambiguous(self) and isinstance(other, datetime): return fix(self) == other else: return False return all(getattr(self, unit) == getattr(other, unit) for unit in self.units) def __repr__(self): return "%s%r" % (self.__class__.__name__, self.tuple()) def tuple(self): """Returns the attributes of the ``adatetime`` object as a tuple of ``(year, month, day, hour, minute, second, microsecond)``. """ return (self.year, self.month, self.day, self.hour, self.minute, self.second, self.microsecond) def date(self): return date(self.year, self.month, self.day) def copy(self): return adatetime(year=self.year, month=self.month, day=self.day, hour=self.hour, minute=self.minute, second=self.second, microsecond=self.microsecond) def replace(self, **kwargs): """Returns a copy of this object with the attributes given as keyword arguments replaced. >>> adt = adatetime(year=2009, month=10, day=31) >>> adt.replace(year=2010) (2010, 10, 31, None, None, None, None) """ newadatetime = self.copy() for key, value in iteritems(kwargs): if key in self.units: setattr(newadatetime, key, value) else: raise KeyError("Unknown argument %r" % key) return newadatetime def floor(self): """Returns a ``datetime`` version of this object with all unspecified (None) attributes replaced by their lowest values. This method raises an error if the ``adatetime`` object has no year. >>> adt = adatetime(year=2009, month=5) >>> adt.floor() datetime.datetime(2009, 5, 1, 0, 0, 0, 0) """ y, m, d, h, mn, s, ms = (self.year, self.month, self.day, self.hour, self.minute, self.second, self.microsecond) if y is None: raise ValueError("Date has no year") if m is None: m = 1 if d is None: d = 1 if h is None: h = 0 if mn is None: mn = 0 if s is None: s = 0 if ms is None: ms = 0 return datetime(y, m, d, h, mn, s, ms) def ceil(self): """Returns a ``datetime`` version of this object with all unspecified (None) attributes replaced by their highest values. This method raises an error if the ``adatetime`` object has no year. >>> adt = adatetime(year=2009, month=5) >>> adt.floor() datetime.datetime(2009, 5, 30, 23, 59, 59, 999999) """ y, m, d, h, mn, s, ms = (self.year, self.month, self.day, self.hour, self.minute, self.second, self.microsecond) if y is None: raise ValueError("Date has no year") if m is None: m = 12 if d is None: d = calendar.monthrange(y, m)[1] if h is None: h = 23 if mn is None: mn = 59 if s is None: s = 59 if ms is None: ms = 999999 return datetime(y, m, d, h, mn, s, ms) def disambiguated(self, basedate): """Returns either a ``datetime`` or unambiguous ``timespan`` version of this object. Unless this ``adatetime`` object is full specified down to the microsecond, this method will return a timespan built from the "floor" and "ceil" of this object. This method raises an error if the ``adatetime`` object has no year. >>> adt = adatetime(year=2009, month=10, day=31) >>> adt.disambiguated() timespan(datetime(2009, 10, 31, 0, 0, 0, 0), datetime(2009, 10, 31, 23, 59 ,59, 999999) """ dt = self if not is_ambiguous(dt): return fix(dt) return timespan(dt, dt).disambiguated(basedate) # Time span class class timespan(object): """A span of time between two ``datetime`` or ``adatetime`` objects. """ def __init__(self, start, end): """ :param start: a ``datetime`` or ``adatetime`` object representing the start of the time span. :param end: a ``datetime`` or ``adatetime`` object representing the end of the time span. """ if not isinstance(start, (datetime, adatetime)): raise TimeError("%r is not a datetime object" % start) if not isinstance(end, (datetime, adatetime)): raise TimeError("%r is not a datetime object" % end) self.start = copy.copy(start) self.end = copy.copy(end) def __eq__(self, other): if not other.__class__ is self.__class__: return False return self.start == other.start and self.end == other.end def __repr__(self): return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) def disambiguated(self, basedate, debug=0): """Returns an unambiguous version of this object. >>> start = adatetime(year=2009, month=2) >>> end = adatetime(year=2009, month=10) >>> ts = timespan(start, end) >>> ts timespan(adatetime(2009, 2, None, None, None, None, None), adatetime(2009, 10, None, None, None, None, None)) >>> td.disambiguated(datetime.now()) timespan(datetime(2009, 2, 28, 0, 0, 0, 0), datetime(2009, 10, 31, 23, 59 ,59, 999999) """ #- If year is in start but not end, use basedate.year for end #-- If year is in start but not end, but startdate is > basedate, # use "next " to get end month/year #- If year is in end but not start, copy year from end to start #- Support "next february", "last april", etc. start, end = copy.copy(self.start), copy.copy(self.end) start_year_was_amb = start.year is None end_year_was_amb = end.year is None if has_no_date(start) and has_no_date(end): # The start and end points are just times, so use the basedate # for the date information. by, bm, bd = basedate.year, basedate.month, basedate.day start = start.replace(year=by, month=bm, day=bd) end = end.replace(year=by, month=bm, day=bd) else: # If one side has a year and the other doesn't, the decision # of what year to assign to the ambiguous side is kind of # arbitrary. I've used a heuristic here based on how the range # "reads", but it may only be reasonable in English. And maybe # even just to me. if start.year is None and end.year is None: # No year on either side, use the basedate start.year = end.year = basedate.year elif start.year is None: # No year in the start, use the year from the end start.year = end.year elif end.year is None: end.year = max(start.year, basedate.year) if start.year == end.year: # Once again, if one side has a month and day but the other side # doesn't, the disambiguation is arbitrary. Does "3 am to 5 am # tomorrow" mean 3 AM today to 5 AM tomorrow, or 3am tomorrow to # 5 am tomorrow? What I picked is similar to the year: if the # end has a month+day and the start doesn't, copy the month+day # from the end to the start UNLESS that would make the end come # before the start on that day, in which case use the basedate # instead. If the start has a month+day and the end doesn't, use # the basedate. start_dm = not (start.month is None and start.day is None) end_dm = not (end.month is None and end.day is None) if end_dm and not start_dm: if start.floor().time() > end.ceil().time(): start.month = basedate.month start.day = basedate.day else: start.month = end.month start.day = end.day elif start_dm and not end_dm: end.month = basedate.month end.day = basedate.day if floor(start).date() > ceil(end).date(): # If the disambiguated dates are out of order: # - If no start year was given, reduce the start year to put the # start before the end # - If no end year was given, increase the end year to put the end # after the start # - If a year was specified for both, just swap the start and end if start_year_was_amb: start.year = end.year - 1 elif end_year_was_amb: end.year = start.year + 1 else: start, end = end, start start = floor(start) end = ceil(end) if start.date() == end.date() and start.time() > end.time(): # If the start and end are on the same day, but the start time # is after the end time, move the end time to the next day end += timedelta(days=1) return timespan(start, end) # Functions for working with datetime/adatetime objects def floor(at): if isinstance(at, datetime): return at return at.floor() def ceil(at): if isinstance(at, datetime): return at return at.ceil() def fill_in(at, basedate, units=adatetime.units): """Returns a copy of ``at`` with any unspecified (None) units filled in with values from ``basedate``. """ if isinstance(at, datetime): return at args = {} for unit in units: v = getattr(at, unit) if v is None: v = getattr(basedate, unit) args[unit] = v return fix(adatetime(**args)) def has_no_date(at): """Returns True if the given object is an ``adatetime`` where ``year``, ``month``, and ``day`` are all None. """ if isinstance(at, datetime): return False return at.year is None and at.month is None and at.day is None def has_no_time(at): """Returns True if the given object is an ``adatetime`` where ``hour``, ``minute``, ``second`` and ``microsecond`` are all None. """ if isinstance(at, datetime): return False return (at.hour is None and at.minute is None and at.second is None and at.microsecond is None) def is_ambiguous(at): """Returns True if the given object is an ``adatetime`` with any of its attributes equal to None. """ if isinstance(at, datetime): return False return any((getattr(at, attr) is None) for attr in adatetime.units) def is_void(at): """Returns True if the given object is an ``adatetime`` with all of its attributes equal to None. """ if isinstance(at, datetime): return False return all((getattr(at, attr) is None) for attr in adatetime.units) def fix(at): """If the given object is an ``adatetime`` that is unambiguous (because all its attributes are specified, that is, not equal to None), returns a ``datetime`` version of it. Otherwise returns the ``adatetime`` object unchanged. """ if is_ambiguous(at) or isinstance(at, datetime): return at return datetime(year=at.year, month=at.month, day=at.day, hour=at.hour, minute=at.minute, second=at.second, microsecond=at.microsecond)