Source code for xoutil.datetime

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
# ---------------------------------------------------------------------
# xoutil.datetime
# ---------------------------------------------------------------------
# Copyright (c) 2013-2017 Merchise Autrement [~°/~] and Contributors
# Copyright (c) 2012 Medardo Rodríguez
# All rights reserved.
#
# This is free software; you can redistribute it and/or modify it under the
# terms of the LICENCE attached (see LICENCE file) in the distribution
# package.
#
# Based on code submitted to comp.lang.python by Andrew Dalke, copied from
# Django and generalized.
#
# Created on 2012-02-15

'''Extends the standard `datetime` module.

- Python's ``datetime.strftime`` doesn't handle dates previous to 1900.
  This module define classes to override `date` and `datetime` to support the
  formatting of a date through its full proleptic Gregorian date range.

Based on code submitted to comp.lang.python by Andrew Dalke, copied from
Django and generalized.

You may use this module as a drop-in replacement of the standard library
`datetime` module.

'''

from __future__ import (division as _py3_division,
                        print_function as _py3_print,
                        absolute_import as _py3_abs_imports)

import sys

from datetime import *    # noqa
import datetime as _stdlib    # noqa

from re import compile as _regex_compile
from time import strftime as _time_strftime


#: Simple constants for .weekday() method
class WEEKDAY:
    MONDAY = 0
    TUESDAY = 1
    WEDNESDAY = 2
    THURSDAY = 3
    FRIDAY = 4
    SATURDAY = 5
    SUNDAY = 6


class ISOWEEKDAY:
    MONDAY = 1
    TUESDAY = 2
    WEDNESDAY = 3
    THURSDAY = 4
    FRIDAY = 5
    SATURDAY = 6
    SUNDAY = 7


try:
    date(1800, 1, 1).strftime("%Y")    # noqa
except ValueError:
    # This happens in Pytnon 2.7, I was considering to replace `strftime`
    # function from `time` module, that is used for all `strftime` methods;
    # but (WTF), Python double checks the year (in each method and then again
    # in `time.strftime` function).

    class date(date):
        __doc__ = date.__doc__

        def strftime(self, fmt):
            return strftime(self, fmt)

        def __sub__(self, other):
            return assure(super(date, self).__sub__(other))

    class datetime(datetime):
        __doc__ = datetime.__doc__

        def strftime(self, fmt):
            return strftime(self, fmt)

        def __sub__(self, other):
            return assure(super(datetime, self).__sub__(other))

        def combine(self, date, time):
            return assure(super(datetime, self).combine(date, time))

        def date(self):
            return assure(super(datetime, self).date())

        @staticmethod
        def now(tz=None):
            return assure(super(datetime, datetime).now(tz=tz))

    def assure(obj):
        '''Make sure that a `date` or `datetime` instance is a safe version.

        With safe it's meant that will use the adapted subclass on this module
        or the standard if these weren't generated.

        Classes that could be assured are: `date`, `datetime`, `time` and
        `timedelta`.

        '''
        t = type(obj)
        name = t.__name__
        if name == date.__name__:
            return obj if t is date else date(*obj.timetuple()[:3])
        elif name == datetime.__name__:
            if t is datetime:
                return obj
            else:
                args = obj.timetuple()[:6] + (obj.microsecond, obj.tzinfo)
                return datetime(*args)
        elif isinstance(obj, (time, timedelta)):
            return obj
        else:
            raise TypeError('Not valid type for datetime assuring: %s' % name)
else:
    def assure(obj):
        '''Make sure that a `date` or `datetime` instance is a safe version.

        This is only a type checker alternative to standard library.

        '''
        if isinstance(obj, (date, datetime, time, timedelta)):
            return obj
        else:
            raise TypeError('Not valid type for datetime assuring: %s' % name)



from xoutil.deprecation import deprecated    # noqa


@deprecated(assure)
[docs]def new_date(d): '''Generate a safe date from a legacy datetime date object.''' return date(d.year, d.month, d.day)
@deprecated(assure)
[docs]def new_datetime(d): '''Generate a safe datetime given a legacy date or datetime object.''' args = [d.year, d.month, d.day] if isinstance(d, datetime.__base__): # legacy datetime args.extend([d.hour, d.minute, d.second, d.microsecond, d.tzinfo]) return datetime(*args)
del deprecated # This library does not support strftime's "%s" or "%y" format strings. # Allowed if there's an even number of "%"s because they are escaped. _illegal_formatting = _regex_compile(br"((^|[^%])(%%)*%[sy])") def _year_find_all(fmt, year, no_year_tuple): text = _time_strftime(fmt, (year,) + no_year_tuple) regex = _regex_compile(str(year)) return {match.start() for match in regex.finditer(text)} _TD_LABELS = 'dhms' # days, hours, minutes, seconds
[docs]def strfdelta(delta): ''' Format a timedelta using a smart pretty algorithm. Only two levels of values will be printed. :: >>> def t(h, m): ... return timedelta(hours=h, minutes=m) >>> strfdelta(t(4, 56)) == '4h 56m' True ''' from xoutil.string import strfnumber ss, sss = str('%s%s'), str(' %s%s') if delta.days: days = delta.days delta -= timedelta(days=days) hours = delta.total_seconds() / 60 / 60 res = ss % (days, _TD_LABELS[0]) if hours >= 0.01: res += sss % (strfnumber(hours), _TD_LABELS[1]) else: seconds = delta.total_seconds() if seconds > 60: minutes = seconds / 60 if minutes > 60: hours = int(minutes / 60) minutes -= hours * 60 res = ss % (hours, _TD_LABELS[1]) if minutes >= 0.01: res += sss % (strfnumber(minutes), _TD_LABELS[2]) else: minutes = int(minutes) seconds -= 60 * minutes res = ss % (minutes, _TD_LABELS[2]) if seconds >= 0.01: res += sss % (strfnumber(seconds), _TD_LABELS[3]) else: res = ss % (strfnumber(seconds, '%0.3f'), _TD_LABELS[3]) return res
[docs]def strftime(dt, fmt): '''Used as `strftime` method of `date` and `datetime` redefined classes. Also could be used with standard instances. ''' if dt.year >= 1900: bases = type(dt).mro() i = 0 base = _strftime = type(dt).strftime while _strftime == base: aux = getattr(bases[i], 'strftime', base) if aux != base: _strftime = aux else: i += 1 return _strftime(dt, fmt) else: illegal_formatting = _illegal_formatting.search(fmt) if illegal_formatting is None: year = dt.year # For every non-leap year century, advance by 6 years to get into # the 28-year repeat cycle delta = 2000 - year year += 6 * (delta // 100 + delta // 400) year += ((2000 - year) // 28) * 28 # Move to around the year 2000 no_year_tuple = dt.timetuple()[1:] sites = _year_find_all(fmt, year, no_year_tuple) sites &= _year_find_all(fmt, year + 28, no_year_tuple) res = _time_strftime(fmt, (year,) + no_year_tuple) syear = "%04d" % dt.year for site in sites: res = res[:site] + syear + res[site + 4:] return res else: msg = 'strftime of dates before 1900 does not handle %s' raise TypeError(msg % illegal_formatting.group(0))
def parse_date(value=None): if value: y, m, d = value.split('-') return date(int(y), int(m), int(d)) else: return date.today()
[docs]def get_month_first(ref=None): '''Given a reference date, returns the first date of the same month. If `ref` is not given, then uses current date as the reference. ''' aux = ref or date.today() y, m = aux.year, aux.month return date(y, m, 1)
[docs]def get_month_last(ref=None): '''Given a reference date, returns the last date of the same month. If `ref` is not given, then uses current date as the reference. ''' aux = ref or date.today() y, m = aux.year, aux.month if m == 12: m = 1 y += 1 else: m += 1 return date(y, m, 1) - timedelta(1)
[docs]def get_next_month(ref=None, lastday=False): '''Get the first or last day of the *next month*. If `lastday` is False return the first date of the `next month`. Otherwise, return the last date. The *next month* is computed with regards to a reference date. If `ref` is None, take the current date as the reference. Examples: >>> get_next_month(date(2017, 1, 23)) date(2017, 2, 1) >>> get_next_month(date(2017, 1, 23), lastday=True) date(2017, 2, 28) .. versionadded:: 1.7.3 ''' result = get_month_last(ref) + timedelta(days=1) if lastday: return get_month_last(result) else: return result
[docs]def is_full_month(start, end): '''Returns true if the arguments comprises a whole month. ''' sd, sm, sy = start.day, start.month, start.year em, ey = end.month, end.year return ((sd == 1) and (sm == em) and (sy == ey) and (em != (end + timedelta(1)).month))
class flextime(timedelta): @classmethod def parse_simple_timeformat(cls, which): if 'h' in which: hour, rest = which.split('h') else: hour, rest = 0, which return int(hour), int(rest), 0 def __new__(cls, *args, **kwargs): first = None if args: first, rest = args[0], args[1:] _super = super(flextime, cls).__new__ if first and not rest and not kwargs: hour, minutes, seconds = cls.parse_simple_timeformat(first) return _super(cls, hours=hour, minutes=minutes, seconds=seconds) else: return _super(cls, *args, **kwargs) # TODO: Merge this with the new time span.
[docs]def daterange(*args): '''Similar to standard 'range' function, but for date objets. Returns an iterator that yields each date in the range of ``[start, stop)``, not including the stop. If `start` is given, it must be a date (or `datetime`) value; and in this case only `stop` may be an integer meaning the numbers of days to look ahead (or back if `stop` is negative). If only `stop` is given, `start` will be the first day of stop's month. `step`, if given, should be a non-zero integer meaning the numbers of days to jump from one date to the next. It defaults to ``1``. If it's positive then `stop` should happen after `start`, otherwise no dates will be yielded. If it's negative `stop` should be before `start`. As with `range`, `stop` is never included in the yielded dates. ''' import operator # Use base classes to allow broader argument values from datetime import date, datetime if len(args) == 1: start, stop, step = None, args[0], None elif len(args) == 2: start, stop = args step = None else: start, stop, step = args if not step and step is not None: raise ValueError('Invalid step value %r' % step) if not start: if not isinstance(stop, (date, datetime)): raise TypeError('stop must a date if start is None') else: start = get_month_first(stop) else: if stop is not None and not isinstance(stop, (date, datetime)): stop = start + timedelta(days=stop) if step is None or step > 0: compare = operator.lt else: compare = operator.gt step = timedelta(days=(step if step else 1)) # Encloses the generator so that signature validation exceptions happen # without needing to call next(). def _generator(): current = start while stop is None or compare(current, stop): yield current current += step return _generator()
if sys.version_info < (3, 0): class infinity_extended_date(date): 'A date that compares to Infinity' def operator(name, T=True): def result(self, other): from xoutil.infinity import Infinity if other is Infinity: return T elif other is -Infinity: return not T else: return getattr(date, name)(self, other) return result # It seems that @total_ordering is worthless because date implements # this operators __le__ = operator('__le__') __ge__ = operator('__ge__', T=False) __lt__ = operator('__lt__') __gt__ = operator('__gt__', T=False) del operator def __eq__(self, other): from xoutil.infinity import Infinity # I have to put this because when doing ``timespan != date`` # Python 2 may chose to call date's __ne__ instead of # TimeSpan.__ne__. I assume the same applies to __eq__. if isinstance(other, _EmptyTimeSpan): return False if isinstance(other, TimeSpan): return TimeSpan.from_date(self) == other elif other is Infinity or other is -Infinity: return False else: return date.__eq__(self, other) def __ne__(self, other): return not (self == other) else: infinity_extended_date = date del sys class DateField(object): '''A simple descriptor for dates. Ensures that assigned values must be parseable dates and parses them. ''' def __init__(self, name, nullable=False): self.name = name self.nullable = nullable def __get__(self, instance, owner): from xoutil.context import context if instance is not None: res = instance.__dict__[self.name] if res and NEEDS_FLEX_DATE in context: return infinity_extended_date(res.year, res.month, res.day) else: return res else: return self def __set__(self, instance, value): from datetime import date # all dates, not just xoutil's if value in (None, False): # We regard False as None, so that working with Odoo is easier: # missing values in Odoo, often come as False instead of None. if not self.nullable: raise ValueError('Setting None to a required field') else: value = None elif not isinstance(value, date): value = parse_date(value) instance.__dict__[self.name] = value
[docs]class TimeSpan(object): '''A *continuous* span of time. Time spans objects are iterable. They yield exactly two times: first the start date, and then the end date:: >>> ts = TimeSpan('2017-08-01', '2017-09-01') >>> tuple(ts) (date(2017, 8, 1), date(2017, 9, 1)) Time spans objects have two items:: >>> ts[0] date(2017, 8, 1) >>> ts[1] date(2017, 9, 1) >>> ts[:] (date(2017, 8, 1), date(2017, 9, 1)) Two time spans are equal if their start_date and end_date are equal. When comparing a time span with a date, the date is coerced to a time span (`from_date`:meth:). A time span with its `start` set to None is unbound to the past. A time span with its `end` set to None is unbound to the future. A time span that is both unbound to the past and the future contains all possible dates. A time span that is not unbound in any direction is `bound <bound>`:attr:. A bound time span is `valid`:attr: if its start date comes before its end date. Time spans can `intersect <__mul__>`:meth:, compared for containment of dates and by the subset/superset order operations (``<=``, ``>=``). In this regard, they represent the *set* of dates between `start` and `end`, inclusively. .. warning:: Time spans don't implement the union or difference operations expected in sets because the difference/union of two span is not necessarily *continuous*. ''' start_date = DateField('start_date', nullable=True) end_date = DateField('end_date', nullable=True) def __init__(self, start_date=None, end_date=None): self.start_date = start_date self.end_date = end_date @classmethod
[docs] def from_date(self, date): '''Return a new time span that covers a single `date`.''' return self(start_date=date, end_date=date)
@property def past_unbound(self): 'True if the time span is not bound into the past.' return self.start_date is None @property def future_unbound(self): 'True if the time span is not bound into the future.' return self.end_date is None @property def unbound(self): '''True if the time span is `unbound into the past <past_unbound>`:attr: or `unbount into the future <future_unbound>`:attr: or both. ''' return self.future_unbound or self.past_unbound @property def bound(self): 'True if the time span is not `unbound <unbound>`:attr:.' return not self.unbound @property def valid(self): '''A bound time span is valid if it starts before it ends. Unbound time spans are always valid. ''' from xoutil.context import context with context(NEEDS_FLEX_DATE): if self.bound: return self.start_date <= self.end_date else: return True def __contains__(self, other): '''Test if we completely cover `other` time span. A time span completely cover another one if every day contained by `other` is also contained by `self`. Another way to define it is that ``self & other == other``. ''' from datetime import date if isinstance(other, date): if self.start_date and self.end_date: return self.start_date <= other <= self.end_date elif self.start_date: return self.start_date <= other elif self.end_date: return other <= self.end_date else: return True else: return False
[docs] def overlaps(self, other): '''Test if the time spans overlaps.''' return bool(self & other)
[docs] def isdisjoint(self, other): return not self.overlaps(other)
[docs] def __le__(self, other): 'True if `other` is a superset.' return (self & other) == self
issubset = __le__ def __lt__(self, other): 'True if `other` is a proper superset.' return self != other and self <= other def __gt__(self, other): 'True if `other` is a proper subset.' return self != other and self >= other
[docs] def __ge__(self, other): 'True if `other` is a subset.' # Notice that ge is not the opposite of lt. return (self & other) == other
issuperset = covers = __ge__ def __iter__(self): yield self.start_date yield self.end_date def __getitem__(self, index): this = tuple(self) return this[index] def __eq__(self, other): import datetime if isinstance(other, datetime.date): other = type(self).from_date(other) if not isinstance(other, TimeSpan): other = type(self)(other) return hash(self) == hash(other) def __hash__(self): return hash((self.start_date, self.end_date)) def __ne__(self, other): return not (self == other)
[docs] def __and__(self, other): '''Get the time span that is the intersection with another time span. If two time spans don't overlap, return the `empty time span <EmptyTimeSpan>`:obj:. If `other` is not a TimeSpan we try to create one. If `other` is a date, we create the TimeSpan that starts and end that very day. Other types are passed unchanged to the constructor. ''' import datetime from xoutil.infinity import Infinity from xoutil.context import context if isinstance(other, _EmptyTimeSpan): return other elif isinstance(other, datetime.date): other = TimeSpan.from_date(other) elif not isinstance(other, TimeSpan): raise TypeError with context(NEEDS_FLEX_DATE): start = max( self.start_date or -Infinity, other.start_date or -Infinity ) end = min( self.end_date or Infinity, other.end_date or Infinity ) if start <= end: if start is -Infinity: start = None if end is Infinity: end = None return type(self)(start, end) else: return EmptyTimeSpan
__mul__ = __and__
[docs] def intersection(self, *others): 'Return ``self [& other1 & ...]``.' import operator from functools import reduce return reduce(operator.mul, others, self)
def __repr__(self): start, end = self return 'TimeSpan(%r, %r)' % (start.isoformat() if start else None, end.isoformat() if end else None)
class _EmptyTimeSpan(object): __slots__ = [] # no inner structure def __bool__(self): return False __nonzero__ = __bool__ def __contains__(self, which): return False # I don't contain noone # The empty is equal only to itself def __eq__(self, which): from datetime import date if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)): # We expect `self` to be a singleton, but pickle protocol 1 does # not warrant to call our __new__. return self is which else: raise TypeError def __ne__(self, which): return not (self == which) # The empty set is a subset of any other set. dates are regarded as the # set that contains that def __le__(self, which): from datetime import date if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)): return True else: raise TypeError # The empty set is only a superset of itself. __ge__ = covers = __eq__ # The empty set is a *proper* subset of any set but itself. The empty # set is disjoint with any other set but itself. __lt__ = isdisjoint = __ne__ # The empty set is a *proper* superset of no one def __gt__(self, which): from datetime import date if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)): return True else: raise TypeError # `empty | x == empty + x == x` def __add__(self, which): from datetime import date if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)): return which else: raise TypeError __or__ = __add__ # `empty & x == empty * x == empty` def __mul__(self, other): from datetime import date if isinstance(other, (TimeSpan, date, _EmptyTimeSpan)): return self else: raise TypeError __and__ = __mul__ def __repr__(self): return 'EmptyTimeSpan' def __new__(cls): res = getattr(cls, '_instance', None) if res is None: res = cls._instance = super(_EmptyTimeSpan, cls).__new__(cls) return res def __reduce__(self): # So that unpickling returns the singleton return type(self), () EmptyTimeSpan = _EmptyTimeSpan() # A context to switch on/off returning a subtype of date from DateFields. # Used within TimeSpan to allow comparison with Infinity. NEEDS_FLEX_DATE = object()