#!/usr/bin/env python
# -*- encoding: utf-8 -*-
# ---------------------------------------------------------------------
# xoutil.datetime
# ---------------------------------------------------------------------
# Copyright (c) 2015 Merchise and Contributors
# Copyright (c) 2013, 2014 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 Feb 15, 2012
'''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.
'''
# TODO: Consider use IoC to extend python datetime module
from __future__ import (division as _py3_division,
print_function as _py3_print,
unicode_literals as _py3_unicode,
absolute_import as _py3_abs_imports)
from datetime import * # noqa
from xoutil.deprecation import deprecated
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")
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)
@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" from a "datetime.date" or "datetime.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)
# 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):
if dt.year >= 1900:
return super(type(dt), dt).strftime(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') % illegal_formatting.group(0)
raise TypeError(msg)
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 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)
[docs]def daterange(*args):
'''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()