"""
Classes and functions form this module are dedicated to the manipulation of
date and time quantities.
Obviously the main classes of this module are :class:`Date`, :class:`Period`,
:class:`Time` and :class:`Month`.
Some helper functions are also provided (to get the current date, ...).
Formats hypothesis:
1. Ideally dates and times should be represented as a valid ISO 8601 strings.
Here are a few exemples:
* 2016-01-01 or 20160101 (for a date)
* 12:00, 1200, 12:00:00 or 120000 (for a time)
* A combination of both: 2016-01-01T12:00
* Optionally the time zone indicator: 2016-01-01T12:00Z
2. For a date, the following will also be accepted yyyymmdd[hh[mn[ss]]] with
yyyy as the year in 4 numbers, mm as the month in 2 numbers, dd as the
day in 2 numbers, hh as the hours (0-24), mn as the minutes and ss as the seconds.
3. For time periods, the following convention applies:
* P starts an ISO 8601 Period definition
* nY, the number of years (n positive integer),
* nM, the number of months (n positive integer),
* nD, the number of days (n positive integer),
* T as a time separator,
* nH, the number of hours (n positive integer),
* nM, the number of minutes (n positive integer),
* nS, the number of seconds (n positive integer)
Examples:
* P1Y <=> is a 1 year period
* P20D <=> is a 20 days period
* PT15H10M55S <=> is a 15 hours, 10 minutes and 55 seconds period
"""
import calendar
import datetime
import functools
import inspect
import operator
import re
from bronx.syntax.decorators import secure_getattr
#: No automatic export
__all__ = []
[docs]def today():
"""Return the date of the day, at 0 hour, 0 minute."""
td = datetime.datetime.today()
return Date(td.year, td.month, td.day, 0, 0)
[docs]def yesterday(base=None):
"""Return the date of yesterday (relative to today or specified ``base`` date)."""
if not base:
base = today()
return base - Period(days=1)
[docs]def tomorrow(base=None):
"""Return the date of tomorrow (relative to today or specified ``base`` date)."""
if not base:
base = today()
return base + Period(days=1)
[docs]def now():
"""Return the date just now, with hours, minutes, seconds and microseconds."""
td = datetime.datetime.now()
return Date(td.year, td.month, td.day, td.hour, td.minute, td.second, td.microsecond)
[docs]def utcnow():
"""Return the date and UTC time just now, with hours, minutes, seconds and microseconds."""
td = datetime.datetime.utcnow()
return Date(td.year, td.month, td.day, td.hour, td.minute, td.second, td.microsecond)
[docs]def at_second():
"""Return the date just now, with only hours, minutes and seconds."""
td = datetime.datetime.now()
return Date(td.year, td.month, td.day, td.hour, td.minute, td.second, 0)
[docs]def at_minute():
"""Return the date just now, with only hours and minutes."""
td = datetime.datetime.now()
return Date(td.year, td.month, td.day, td.hour, td.minute, 0, 0)
[docs]def at_hour():
"""Return the date just now, with only hours."""
td = datetime.datetime.now()
return Date(td.year, td.month, td.day, td.hour, 0, 0, 0)
[docs]def lastround(rh=1, delta=0, base=None):
"""Return the date just before ``base`` with a plain hour multiple of ``rh``."""
if not base:
base = now()
if delta:
base += Period(delta)
return Date(base.year, base.month, base.day, base.hour - base.hour % rh, 0)
[docs]def synop(delta=0, base=None, time=None, step=6):
"""Return the date associated to the last synoptic hour."""
synopdate = lastround(step, delta, base)
if time is not None:
time = Time(time)
if time in [Time(x) for x in range(0, 24, step)]:
dt = Period('PT{!s}H'.format(step))
while synopdate.time() != time:
synopdate = synopdate - dt
else:
raise ValueError('Not a synoptic hour: {!s}'.format(time))
return synopdate
[docs]def stamp():
"""Return a date up to microseconds as a tuple."""
td = datetime.datetime.now()
return (td.year, td.month, td.day, td.hour, td.minute, td.second, td.microsecond)
[docs]def easter(year=None):
"""Return the date for easter of the given year
>>> dates = [2013, 2014, 2015, 2016, 2017, 2018]
>>> [easter(d).ymd for d in dates] # doctest: +ELLIPSIS
[...'20130331', ...'20140420', ...'20150405', ...'20160327', ...'20170416', ...'20180401']
"""
if not year:
year = today().year
g = year % 19
c = year // 100
h = (c - c // 4 - (8 * c + 13) // 25 + 19 * g + 15) % 30
i = h - (h // 28) * (1 - (29 // (h + 1)) * ((21 - g) // 11))
j = (year + year // 4 + i + 2 - c + c // 4) % 7
k = i - j
month = 3 + (k + 40) // 44
day = k + 28 - 31 * (month // 4)
return Date(year, month, day)
#: The list of helper date functions
local_date_functions = {
x.__name__: x
for x in locals().values()
if inspect.isfunction(x) and x.__doc__ and x.__doc__.startswith('Return the date')
}
[docs]def mkisodate(datestr):
"""A crude attempt to reshape the iso8601 format."""
ldate = list(re.sub(r' ?(UTC|GMT)$', '', datestr.strip()))
if len(ldate) > 4 and ldate[4] != '-':
ldate[4:4] = ['-', ]
if len(ldate) > 7 and ldate[7] != '-':
ldate[7:7] = ['-', ]
if len(ldate) > 10 and ldate[10] != 'T':
if ldate[10] in (' ', '-', 'H'):
ldate[10] = 'T'
else:
ldate[10:10] = ['T', ]
if 10 < len(ldate) <= 13:
ldate.extend(['0', '0'])
if len(ldate) > 13 and ldate[13] != ':':
ldate[13:13] = [':', ]
if len(ldate) > 16 and ldate[16] != ':':
ldate[16:16] = [':', ]
if len(ldate) > 13 and ldate[-1] != 'Z':
ldate.append('Z')
return ''.join(ldate)
[docs]def stardates():
"""Nice dump of predefined dates functions."""
for k, v in sorted(local_date_functions.items()):
print(k.ljust(12), v())
[docs]def guess(*args):
"""Do our best to find a :class:`Date` or :class:`Period` object compatible with ``args``."""
for isoclass in (Date, Period):
try:
return isoclass(*args)
except (ValueError, TypeError):
continue
raise ValueError("Cannot guess what Period or Date could be {!s}".format(args))
[docs]def daterange(start, end=None, step='P1D'):
"""Date generator.
:param start: A :class:`Date` object or something that can be converted to one.
:param end: A :class:`Date` object or something that can be converted to one.
:param step: A :class:`Period` object or something that can be converted to one.
:func:`daterange` always returns a generator object::
>>> daterange('2017010100', '2017013100', 'P14D') # doctest: +ELLIPSIS
<generator object daterange at 0x...>
>>> list(daterange('2017010100', '2017013100', 'P14D'))
[Date(2017, 1, 1, 0, 0), Date(2017, 1, 15, 0, 0), Date(2017, 1, 29, 0, 0)]
The *start* and *end* attributes are always sorted::
>>> list(daterange('2017013100', '2017010100', 'P14D'))
[Date(2017, 1, 1, 0, 0), Date(2017, 1, 15, 0, 0), Date(2017, 1, 29, 0, 0)]
When *end* is not provided, it is assumed to be 10 days after *start*::
>>> list(daterange('2017010100')) # doctest: +NORMALIZE_WHITESPACE
[Date(2017, 1, 1, 0, 0), Date(2017, 1, 2, 0, 0), Date(2017, 1, 3, 0, 0),
Date(2017, 1, 4, 0, 0), Date(2017, 1, 5, 0, 0), Date(2017, 1, 6, 0, 0),
Date(2017, 1, 7, 0, 0), Date(2017, 1, 8, 0, 0), Date(2017, 1, 9, 0, 0),
Date(2017, 1, 10, 0, 0), Date(2017, 1, 11, 0, 0)]
"""
if not isinstance(start, Date):
start = Date(start)
if end is None:
end = start + Period('P10D')
else:
if not isinstance(end, Date):
end = Date(end)
start, end = sorted((start, end))
if not isinstance(step, Period):
step = Period(step)
if step.total_seconds() < 0:
step = Period(step.length)
rollingdate = start
while rollingdate <= end:
yield rollingdate
rollingdate += step
[docs]def daterangex(start, end=None, step=None, shift=None, fmt=None, prefix=None):
"""Extended date range expansion (returns a list).
Except when ``fmt`` or ``prefix`` are specified, a list of :class:`Date`
objects is returned.
:func:`daterangex` accepts many arguments combinations::
>>> daterangex('2017010100', '2017013100', 'P14D')
[Date(2017, 1, 1, 0, 0), Date(2017, 1, 15, 0, 0), Date(2017, 1, 29, 0, 0)]
>>> daterangex('2017010100-2017013100-P14D')
[Date(2017, 1, 1, 0, 0), Date(2017, 1, 15, 0, 0), Date(2017, 1, 29, 0, 0)]
>>> daterangex('2017010100/2017013100/P14D')
[Date(2017, 1, 1, 0, 0), Date(2017, 1, 15, 0, 0), Date(2017, 1, 29, 0, 0)]
*lists*, *tuples* or comma-separated string can be provided::
>>> daterangex(['2017010100-2017013100-P14D',
... '2017060100-2017063000-P14D',
... '2017122500']) # doctest: +NORMALIZE_WHITESPACE
[Date(2017, 1, 1, 0, 0), Date(2017, 1, 15, 0, 0), Date(2017, 1, 29, 0, 0),
Date(2017, 6, 1, 0, 0), Date(2017, 6, 15, 0, 0), Date(2017, 6, 29, 0, 0),
Date(2017, 12, 25, 0, 0)]
>>> daterangex('2017010100-2017013100-P14D,' +
... '2017060100-2017063000-P14D,' +
... '2017122500') # doctest: +NORMALIZE_WHITESPACE
[Date(2017, 1, 1, 0, 0), Date(2017, 1, 15, 0, 0), Date(2017, 1, 29, 0, 0),
Date(2017, 6, 1, 0, 0), Date(2017, 6, 15, 0, 0), Date(2017, 6, 29, 0, 0),
Date(2017, 12, 25, 0, 0)]
Extra features are provided such as ; applying a global *shift*::
>>> daterangex('2017010100-2017013100-P14D,2017060100-2017063000-P14D,2017122500',
... shift='P1D') # doctest: +NORMALIZE_WHITESPACE
[Date(2017, 1, 2, 0, 0), Date(2017, 1, 16, 0, 0), Date(2017, 1, 30, 0, 0),
Date(2017, 6, 2, 0, 0), Date(2017, 6, 16, 0, 0), Date(2017, 6, 30, 0, 0),
Date(2017, 12, 26, 0, 0)]
Formatting the date as a string depending on the *fmt* function name::
>>> daterangex('2017010100-2017013100-P14D,2017060100-2017063000-P14D,2017122500',
... fmt='ymdh') # doctest: +NORMALIZE_WHITESPACE,+ELLIPSIS
[...'2017010100', ...'2017011500', ...'2017012900',
...'2017060100', ...'2017061500', ...'2017062900', ...'2017122500']
Formatting the date as a string depending on the *fmt* format string::
>>> daterangex('2017010100-2017013100-P14D,2017060100-2017063000-P14D,2017122500',
... fmt='{0.year:04d}') # doctest: +NORMALIZE_WHITESPACE,+ELLIPSIS
[...'2017']
Adding a *prefix* before the date string::
>>> daterangex('2017010100-2017013100-P14D,2017060100-2017063000-P14D,2017122500',
... fmt='ymdh', prefix='loop') # doctest: +NORMALIZE_WHITESPACE,+ELLIPSIS
[...'loop2017010100', ...'loop2017011500', ...'loop2017012900',
...'loop2017060100', ...'loop2017061500', ...'loop2017062900', ...'loop2017122500']
"""
rangevalues = list()
pstarts = ([str(s) for s in start]
if isinstance(start, (list, tuple)) else str(start).split(','))
for pstart in pstarts:
actualrange = re.split('[-/]', pstart)
realstart = Date(actualrange.pop(0))
if actualrange:
realend = Date(actualrange.pop(0))
elif end is None:
realend = realstart
else:
realend = Date(end)
if actualrange:
realstep = Period(actualrange.pop())
elif step is None:
realstep = Period('PT1H')
else:
realstep = Period(step)
if shift is not None:
realshift = Period(shift)
realstart += realshift
realend += realshift
pvalues = daterange(realstart, realend, realstep)
if pvalues:
if fmt is not None:
if fmt.startswith('%'):
fmt = '{0:' + fmt[1:] + '}'
if '{' in fmt and '}' in fmt:
pvalues = [fmt.format(x, i + 1, type(x).__name__)
for i, x in enumerate(pvalues)]
else:
pvalues = [getattr(x, fmt) for x in pvalues]
if callable(pvalues[0]):
pvalues = [x() for x in pvalues]
if prefix is not None:
pvalues = [prefix + str(x) for x in pvalues]
rangevalues.extend(pvalues)
return sorted(set(rangevalues))
[docs]def timerangex(start, end=None, step=None, shift=None, fmt=None, prefix=None):
"""Extended time range expansion (returns a list).
Except when ``fmt`` or ``prefix`` are specified, a list of :class:`Time`
objects is returned.
When ``start`` is already a complex definition (as a string), ``end`` and
``step`` only apply as default when the sub-definition in ``start`` does not
contain any ``end`` or ``step`` value.
Basic examples::
>>> timerangex(0, 12, 3)
[Time(0, 0), Time(3, 0), Time(6, 0), Time(9, 0), Time(12, 0)]
>>> timerangex('0-12-3')
[Time(0, 0), Time(3, 0), Time(6, 0), Time(9, 0), Time(12, 0)]
>>> timerangex('0-12-3', shift=24)
[Time(24, 0), Time(27, 0), Time(30, 0), Time(33, 0), Time(36, 0)]
>>> timerangex(0, 12, 3, shift=24)
[Time(24, 0), Time(27, 0), Time(30, 0), Time(33, 0), Time(36, 0)]
>>> print(', '.join(timerangex('0-12-3', shift=24, fmt='{0.hour:03d}')))
024, 027, 030, 033, 036
>>> print(', '.join(timerangex('0-12-3', shift=24, fmt='fmthm')))
0024:00, 0027:00, 0030:00, 0033:00, 0036:00
Hour/Minutes examples::
>>> timerangex(0, 3, '0:30')
[Time(0, 0), Time(0, 30), Time(1, 0), Time(1, 30), Time(2, 0), Time(2, 30), Time(3, 0)]
>>> timerangex('0:00', '3:00', '0:30')
[Time(0, 0), Time(0, 30), Time(1, 0), Time(1, 30), Time(2, 0), Time(2, 30), Time(3, 0)]
>>> timerangex(0, 3, '0:30', shift=24)
[Time(24, 0), Time(24, 30), Time(25, 0), Time(25, 30), Time(26, 0), Time(26, 30), Time(27, 0)]
It also works with negative values::
>>> timerangex(3, 0,'-0:30')
[Time(0, 0), Time(0, 30), Time(1, 0), Time(1, 30), Time(2, 0), Time(2, 30), Time(3, 0)]
>>> timerangex(-3, 0,'0:30')
[Time(-3, 0), Time(-2, -30), Time(-2, 0), Time(-1, -30), Time(-1, 0), Time(0, -30), Time(0, 0)]
>>> timerangex(-3, 0, 1)
[Time(-3, 0), Time(-2, 0), Time(-1, 0), Time(0, 0)]
With complex strings::
>>> timerangex('0-12-3,18-36-6,48') # doctest: +NORMALIZE_WHITESPACE
[Time(0, 0), Time(3, 0), Time(6, 0), Time(9, 0), Time(12, 0), Time(18, 0),
Time(24, 0), Time(30, 0), Time(36, 0), Time(48, 0)]
"""
rangevalues = list()
# Very strange case of an empty range
if start is None:
return list()
pstarts = ([str(s) for s in start]
if isinstance(start, (list, tuple)) else str(start).split(','))
for pstart in pstarts:
realstart = pstart
if pstart.startswith('-'):
realstart = '__MINUS__' + pstart[1:]
if '--' in realstart:
realstart = realstart.replace('--', '/__MINUS__').replace('-', '/')
realstart = realstart.replace('-', '/')
realstart = realstart.replace('__MINUS__', '-')
actualrange = realstart.split('/')
realstart = Time(actualrange[0])
if len(actualrange) > 1:
realend = actualrange[1]
elif end is None:
realend = realstart
else:
realend = end
realend = Time(realend)
if len(actualrange) > 2:
realstep = actualrange[2]
elif step is None:
realstep = 1
else:
realstep = step
realstep = Time(realstep)
if shift is not None:
realshift = Time(shift)
realstart += realshift
realend += realshift
signstep = int(realstep > 0) * 2 - 1
pvalues = [realstart, ]
while signstep * (pvalues[-1] - realend) < 0:
pvalues.append(pvalues[-1] + realstep)
if signstep * (pvalues[-1] - realend) > 0:
pvalues.pop()
pvalues.sort()
if fmt is not None:
if '{' in fmt and '}' in fmt:
pvalues = [fmt.format(x, i + 1, type(x).__name__)
for i, x in enumerate(pvalues)]
else:
pvalues = [getattr(x, fmt) for x in pvalues]
if callable(pvalues[0]):
pvalues = [x() for x in pvalues]
if prefix is not None:
pvalues = [prefix + str(x) for x in pvalues]
rangevalues.extend(pvalues)
return sorted(set(rangevalues))
[docs]def timeintrangex(start, end=None, step=None, shift=None, fmt=None, prefix=None):
"""Extended range expansion (returns a list).
This function is built on top of :func:`~bronx.stdtypes.date.timerangex`.
It uses the :class:`~bronx.stdtypes.date.TimeInt` class instead of the
:class:`~bronx.stdtypes.date.Time` class. Practically, if the 'range' is
just made of basic integers (e.g. just hours), a list of integers is
returned. If minutes are needed, a list of string formated as
'hhhh:mm' is returned.
When ``start`` is already a complex definition (as a string), ``end`` and
``step`` only apply as default when the sub-definition in ``start`` does not
contain any ``end`` or ``step`` value.
All the examples below refers to :func:`~bronx.stdtypes.date.timeintrangex`.
However, they are fully relevant for :func:`~footprints.util.rangex` (since
it is an alias of :func:`~bronx.stdtypes.date.timeintrangex`).
Basic examples::
>>> timeintrangex(0, 12, 3)
[0, 3, 6, 9, 12]
>>> timeintrangex('0-12-3')
[0, 3, 6, 9, 12]
>>> timeintrangex('0-12-3', shift=24)
[24, 27, 30, 33, 36]
>>> timeintrangex(0, 12, 3, shift=24)
[24, 27, 30, 33, 36]
>>> print(', '.join(timeintrangex('0-12-3', shift=24, fmt='%03d')))
024, 027, 030, 033, 036
Hour/Minutes examples::
>>> print(', '.join(timeintrangex(0, 3, '0:30')))
0000:00, 0000:30, 0001:00, 0001:30, 0002:00, 0002:30, 0003:00
>>> print(', '.join(timeintrangex('0:00', '3:00', '0:30')))
0000:00, 0000:30, 0001:00, 0001:30, 0002:00, 0002:30, 0003:00
>>> print(', '.join(timeintrangex(0, 3, '0:30', shift=24)))
0024:00, 0024:30, 0025:00, 0025:30, 0026:00, 0026:30, 0027:00
It also works with negative values::
>>> print(', '.join(timeintrangex(3, 0,'-0:30')))
0000:00, 0000:30, 0001:00, 0001:30, 0002:00, 0002:30, 0003:00
>>> print(', '.join(timeintrangex(-3, 0,'0:30')))
-0000:30, -0001:00, -0001:30, -0002:00, -0002:30, -0003:00, 0000:00
>>> timeintrangex(-3, 0, 1)
[-3, -2, -1, 0]
With complex strings::
>>> timeintrangex('0-12-3,18-36-6,48')
[0, 3, 6, 9, 12, 18, 24, 30, 36, 48]
"""
pstarts = ([str(s) for s in start]
if isinstance(start, (list, tuple)) else str(start).split(','))
auto_prefix = None
auto_pstarts = list()
for pstart in pstarts:
if re.search('_', pstart):
auto_prefix, realstart = pstart.split('_')
auto_prefix += '_'
auto_pstarts.append(realstart)
if auto_prefix:
prefix = auto_prefix
start = auto_pstarts
trx = timerangex(start, end=end, step=step, shift=shift)
if all([isinstance(x, Time) for x in trx]):
trx = [TimeInt(x) for x in trx]
if all([x.is_int() for x in trx]):
pvalues = [x.value for x in trx]
else:
pvalues = [x.str_time for x in trx]
else:
pvalues = trx
if fmt is not None:
if fmt.startswith('%'):
fmt = '{0:' + fmt[1:] + '}'
pvalues = [fmt.format(x, i + 1, type(x).__name__)
for i, x in enumerate(pvalues)]
if prefix is not None:
pvalues = [prefix + str(x) for x in pvalues]
return sorted(pvalues)
[docs]class Period(datetime.timedelta):
"""
Standard period objects, extending :class:`datetime.timedelta` features
with iso8601 capabilities.
"""
_my_re = re.compile(
r'(?P<X>[+-]?P)(?P<Y>[0-9]+([,.][0-9]+)?Y)?'
r'(?P<M>[0-9]+([,.][0-9]+)?M)?'
r'(?P<W>[0-9]+([,.][0-9]+)?W)?'
r'(?P<D>[0-9]+([,.][0-9]+)?D)?'
r'((?P<T>T)(?P<h>[0-9]+([,.][0-9]+)?H)?'
r'(?P<m>[0-9]+([,.][0-9]+)?M)?'
r'(?P<s>[0-9]+([,.][0-9]+)?S)?)?$'
)
@staticmethod
def _period_regex(s):
return Period._my_re.match(s)
_const_times = [
# in a [0], there are [1] [2]
('m', 60, 's'),
('h', 60, 'm'),
('D', 24, 'h'),
('W', 7, 'D'),
('M', 31, 'D'),
('Y', 365, 'D'),
]
@staticmethod
def _adder(key, value):
if key == 's':
return value
else:
for key1, factor, key2 in Period._const_times:
if key == key1:
return Period._adder(key2, factor * value)
raise KeyError("Unknown key in Period string: %s" % key)
@staticmethod
def _parse(string):
"""Find out time duration that could be extracted from string argument."""
if not isinstance(string, str):
raise TypeError("Expected string input")
if len(string) < 2:
raise ValueError("Badly formed short string %s" % string)
match = Period._period_regex(string)
if not match:
raise ValueError("Badly formed string %s" % string)
values = match.groupdict()
values.pop('T')
sign = values.pop('X')
if sign.startswith('-'):
sign = -1
else:
sign = 1
for k, v in values.items():
if not v:
values[k] = 0
else:
values[k] = int(v[:-1])
secs = 0
for k, v in values.items():
secs += Period._adder(k, v)
return sign * secs
def __new__(cls, *args, **kw):
"""
The object can be constructed from:
* a standard :class:`datetime.timedelta` object;
* named attributes compatible with the :class:`datetime.timedelta` class;
* a Vortex's :class:`Time` or :class:`Period` object;
* a string that could be reshaped as an ISO 8601 date string
(see the description of the ISO 8601 convention at the top of
this page);
* one integer or float (number of seconds)
* two integers (number of days, number of seconds)
These four objects are identical::
>>> Period(days=2, hours=1, seconds=30)
Period(days=2, seconds=3630)
>>> Period('P2DT1H30S')
Period(days=2, seconds=3630)
>>> Period(176430)
Period(days=2, seconds=3630)
>>> Period(2, 3630)
Period(days=2, seconds=3630)
Addition and subtraction are implemented (if the other operand is not a
:class:`Period` object, a conversion is attempted)::
>>> Period('PT6H') + Period('PT6H')
Period(seconds=43200)
>>> Period('PT6H') + 'PT6H'
Period(seconds=43200)
>>> Period('PT6H') + 43200
Period(seconds=64800)
Multiplication (by an integer) is implemented::
>>> Period('PT6H') * 2
Period(seconds=43200)
Comparison operators are all available.
"""
if kw:
args = (datetime.timedelta(**kw),)
if not args:
raise ValueError("No initial value provided for Period")
top = args[0]
ld = list()
if isinstance(top, datetime.timedelta):
ld = [top.days, top.seconds, top.microseconds]
elif isinstance(top, Time):
ld = [0, top.hour * 3600 + top.minute * 60]
elif len(args) < 2 and (isinstance(top, int) or isinstance(top, float)):
ld = [0, top]
elif isinstance(top, int) and len(args) > 1:
ld = list(args)
elif isinstance(top, str):
ld = [0, Period._parse(top)]
if not ld:
raise ValueError("Initial Period value unknown")
return datetime.timedelta.__new__(cls, *ld)
def __reduce__(self):
"""Return a compatible args sequence for the Period constructor (used by :mod:`pickle`)."""
return self.__class__, (self.days, self.seconds, self.microseconds)
def __reduce_ex__(self, protocol):
"""Otherwise datetime.timedelta's __reduce_ex__ method may be called."""
return self.__reduce__()
def __deepcopy__(self, memo):
newinstance = type(self)(self)
memo[id(self)] = newinstance
return newinstance
def __len__(self):
return self.days * 86400 + self.seconds
def __add__(self, delta):
"""
Add to a Period object the specified ``delta`` which could be either
a string or a :class:`datetime.timedelta` or an ISO 6801 Period.
"""
if not isinstance(delta, datetime.timedelta):
delta = Period(delta)
return Period(super().__add__(datetime.timedelta(delta.days, delta.seconds)))
def __sub__(self, delta):
"""
Substract to a Period object the specified ``delta`` which could be either
a string or a :class:`datetime.timedelta` or an ISO 6801 Period.
"""
if not isinstance(delta, datetime.timedelta):
delta = Period(delta)
return Period(super().__sub__(datetime.timedelta(delta.days, delta.seconds)))
def __mul__(self, factor):
"""
Add to a Period object the specified ``delta`` which could be either
a string or a :class:`datetime.timedelta` or an ISO 6801 Period.
"""
if not isinstance(factor, int):
factor = int(factor)
return Period(super().__mul__(factor))
[docs] def iso8601(self):
"""Plain ISO 8601 representation."""
iso = 'P'
sign, days, seconds = '', self.days, self.seconds
if days < 0:
sign = '-'
days += 1
seconds = 86400 - seconds
if days:
iso += '{!s}D'.format(abs(days))
return sign + iso + 'T{!s}S'.format(seconds)
[docs] def export_dict(self):
"""Return the month and year as a tuple."""
return (self.days, self.seconds)
@property
def length(self):
"""Absolute length in seconds."""
return abs(int(self.total_seconds()))
@property
def pseconds(self):
"""The period expressed in seconds (integer)"""
return int(self.total_seconds())
@property
def pminutes(self):
"""The period expressed in minnutes (float)"""
return self.pseconds / 60
[docs] def time(self):
"""Return a :class:`Time` object."""
return Time(0, int(self.total_seconds()) // 60)
@property
def hms(self):
"""Nicely formatted HH:MM:SS string."""
hours, mins = divmod(self.length, 3600)
mins, seconds = divmod(mins, 60)
return '{:02d}:{:02d}:{:02d}'.format(hours, mins, seconds)
@property
def hmscompact(self):
"""Compact HHMMSS string."""
return self.hms.replace(':', '')
def __str__(self):
return self.isoformat()
def __repr__(self):
"""Python changes the repr for timedelta between 3.5 and 3.7
This forces the 3.7 way for all versions.
"""
args = []
if self.days:
args.append("days=%d" % self.days)
if self.seconds:
args.append("seconds=%d" % self.seconds)
if self.microseconds:
args.append("microseconds=%d" % self.microseconds)
if not args:
args.append('0')
return "{}({})".format(self.__class__.__name__, ', '.join(args))
_getattr_re = re.compile(r'^time_(?P<what>(?:fmt)[^_]+)$')
@secure_getattr
def __getattr__(self, name):
"""Proxy to time properties."""
match = self._getattr_re.match(name)
if match is not None:
target = match.group('what')
return getattr(self.time(), target)
else:
raise AttributeError("'{}' object has no attribute '{}'".format(self.__class__.__name__,
name))
class _GetattrCalculatorMixin:
"""This Mixin class adds the capability to do computations using fake methods.
This can be useful during the footprint's replacement process.
"""
_getattr_re = re.compile(r'^(?P<basics>(?:(?:add|sub)(?![^_]*(add|sub)[^_]*_)[^_]+_?)+)(?:_(?P<fmt>[^_]+))?(?<!_)$')
_getattr_basic_re = re.compile(r'^(?P<op>add|sub)(?P<operand>[^_]+)')
_getattr_proxyclass = None
def _basic_calculator_proxy(self, name):
"""Return something that may (or not) create a _getattr_proxyclass object.
The sign of the _getattr_proxyclass object depends on the chosen operation.
If _getattr_proxyclass is None, the current object's class is used.
"""
# The match should always succeed because _getattr_re was called before
dmatch = self._getattr_basic_re.match(name).groupdict()
factor = dict(add=1, sub=-1)[dmatch['op']]
if self._getattr_proxyclass is None:
proxyclass = self.__class__
else:
proxyclass = self._getattr_proxyclass
# Determine if the operand is a valid period (like PT6H)
try:
p = proxyclass(dmatch['operand'])
except ValueError:
p = None
# The easy case: just return the appropriate object
if p is not None:
return factor * p
# Returns a function that looks up into guess and extras (given by footprints)
else:
def _calculator_op_proxy(guess, extra):
t = guess.get(dmatch['operand'], extra.get(dmatch['operand'], None))
if t is None:
raise KeyError("'{}' was not found in guess nor in extra.".
format(dmatch['operand']))
return factor * proxyclass(t)
return _calculator_op_proxy
@secure_getattr
def __getattr__(self, name):
"""Proxy to additions and subtractions (used in footprint's replacement).
:example:
* self.addPT6H is equivalent to (self + 'PT6H')
* self.addPT6H_ymdh is equivalent to (self + 'PT6H').ymdh
* self.addterm_ymdh is equivalent to (self + [term]).ymdh
* It is possible to combine several add and sub, like in:
self.addterm_subPT3H_ymdh
"""
match = self._getattr_re.match(name)
if match is not None:
dmatch = match.groupdict()
basics = dmatch['basics'].rstrip('_').split('_')
fmt = dmatch['fmt']
proxies = [self._basic_calculator_proxy(basic) for basic in basics]
fancy = any([callable(proxy) for proxy in proxies])
if fancy:
def _combi_proxy(guess, extra):
newobj = self
for proxy in proxies:
newobj += proxy(guess, extra) if callable(proxy) else proxy
return newobj if fmt is None else getattr(newobj, fmt)
return _combi_proxy
else:
newobj = self + functools.reduce(operator.add, proxies)
return newobj if fmt is None else getattr(newobj, fmt)
else:
raise AttributeError("'{}' object has no attribute '{}'".format(self.__class__.__name__,
name))
[docs]class Date(datetime.datetime, _GetattrCalculatorMixin):
"""
Standard date objects, extending :class:`datetime.datetime` features with
iso8601 capabilities.
"""
_origin = datetime.datetime(1970, 1, 1, 0, 0, 0)
_getattr_proxyclass = Period
def __new__(cls, *args, **kw):
if kw and not args:
args = (datetime.datetime(**kw),)
if not args:
raise ValueError("No initial value provided for Date")
top = args[0]
deltas = []
ld = list()
if isinstance(top, str) and top in local_date_functions:
try:
top = local_date_functions[top](**kw)
kw = dict()
except (ValueError, TypeError):
pass
if isinstance(top, datetime.datetime):
ld = [top.year, top.month, top.day, top.hour, top.minute, top.second]
elif isinstance(top, tuple) or isinstance(top, list):
ld = list(top)
elif isinstance(top, float):
top = Date._origin + datetime.timedelta(0, top)
ld = [top.year, top.month, top.day, top.hour, top.minute, top.second]
elif isinstance(top, str):
s_top = top.split('/')
top = s_top[0]
top = re.sub('^YYYY', str(max(0, int(kw.pop('year', today().year)))), top.upper())
deltas = s_top[1:]
ld = [int(x) for x in re.split('[-:HTZ]+', mkisodate(top)) if re.match(r'\d+$', x)]
else:
ld = [int(x) for x in args
if isinstance(x, (int, float)) or (isinstance(x, str) and re.match(r'\d+$', x))]
if not ld:
raise ValueError("Initial Date value unknown (args: {!s}, kw: {!s})".format(args, kw))
newdate = datetime.datetime.__new__(cls, *ld)
if deltas:
newdate += sum([Period(d) for d in deltas], Period(0))
return newdate
def __init__(self, *args, **kw): # @UnusedVariable
"""
The object can be constructed from:
* a standard :class:`datetime.datetime` object;
* named attributes compatible with the :class:`datetime.datetime` class;
* a Vortex's :class:`Date` object;
* a tuple containing at least (year, month, day) values (optionally hours);
* a string that could be reshaped as an ISO 8601 date string.
* a string with one or more time deltas (e.g. 201509010600/-PT1H/-PT2H)
* the name of one of the helper date functions (see below)
* a float representing a number of seconds since the epoch time
Here are a few equivalent examples::
Date(2017, 1, 1, 12, 0)
>>> Date(2017, 1, 1, 12)
Date(2017, 1, 1, 12, 0)
>>> Date([2017, 1, 1, 12])
Date(2017, 1, 1, 12, 0)
>>> Date('2017-01-01T12:00')
Date(2017, 1, 1, 12, 0)
>>> Date('2017010112')
Date(2017, 1, 1, 12, 0)
Helper functions can be used::
>>> Date('now') # doctest: +SKIP
Date(2017, 8, 21, 19, 33, 46)
>>> Date('easter') # doctest: +SKIP
Date(2017, 4, 16, 0, 0)
Let's do some calculations while creating the object::
>>> Date('20170101/PT12H')
Date(2017, 1, 1, 12, 0)
>>> Date('2017010212/-P1D')
Date(2017, 1, 1, 12, 0)
>>> Date('2017010200/-P1D/PT12H')
Date(2017, 1, 1, 12, 0)
The addition is defined (if the operand is not a :class:`Period` object,
a conversion is attempted)::
>>> Date('2017010100') + Period('PT12H')
Date(2017, 1, 1, 12, 0)
>>> Date('2017010100') + Time(12, 00)
Date(2017, 1, 1, 12, 0)
>>> Date('2017010100') + 'PT12H'
Date(2017, 1, 1, 12, 0)
>>> Date('2017010100') + 43200
Date(2017, 1, 1, 12, 0)
The subtraction is also defined (the operand can be either a :class:`Period`
object or a :class:`Date` object)::
>>> Date('2017010100') - Period('PT12H')
Date(2016, 12, 31, 12, 0)
>>> Date('2017010100') - 'PT12H'
Date(2016, 12, 31, 12, 0)
>>> Date('2017010100') - Date('2017010212')
Period(days=-2, seconds=43200)
>>> Date('2017010100') - '2017010212'
Period(days=-2, seconds=43200)
Comparison operators are all available.
The :class:`Date` also have the ability to generate dynamic properties
on the fly (because it inherits the :class:`_GetattrCalculatorMixin`
mix in). Such dynamic properties can be very useful when used with
:mod:`footprints` package substitution mechanism. It allows to do
calculations on the fly::
>>> date = Date('20170416')
>>> date
Date(2017, 4, 16, 0, 0)
>>> date.addPT6H
Date(2017, 4, 16, 6, 0)
>>> date.subP1D
Date(2017, 4, 15, 0, 0)
>>> date.subP1D_addPT6H
Date(2017, 4, 15, 6, 0)
>>> print(date.subP1D_ymdh)
2017041500
The following will also work::
>>> print(date.addterm_ymdh(dict(term=Time(6, 0)), dict()))
2017041606
In such a documentation, this looks horrible. However, in the context of
:mod:`footprints` substitutions, it works like a charm (to compute the
validity date of a a resource that defines a *term*).
"""
super().__init__()
delta_o = self - Date._origin
self._epoch = delta_o.days * 86400 + delta_o.seconds
def __reduce__(self):
"""Return a compatible args sequence for the Date constructor (used by :mod:`pickle`)."""
return self.__class__, (self.year, self.month, self.day, self.hour, self.minute, self.second)
def __reduce_ex__(self, protocol):
"""Otherwise datetime.datetime's __reduce_ex__ method might be called."""
return self.__reduce__()
def __deepcopy__(self, memo):
newinstance = type(self)(self)
memo[id(self)] = newinstance
return newinstance
def __hash__(self):
"""Force the object to be hashable (needed with Python3)."""
return datetime.datetime.__hash__(self)
@property
def origin(self):
"""Origin date... far far ago at the very beginning of the 70's."""
return Date(Date._origin)
@property
def epoch(self):
"""Seconds since the beginning of epoch... the first of january, 1970."""
return self._epoch
[docs] def iso8601(self):
"""Plain ISO 8601 representation."""
return self.isoformat() + 'Z'
[docs] def as_datetime(self):
"""Silly enough, but could be usefull to retrieve a raw ``datetime.datetime`` object."""
return datetime.datetime(self.year, self.month, self.day, self.hour, self.minute, self.second, self.microsecond)
def __str__(self):
"""Default string representation is iso8601."""
return self.iso8601()
[docs] def is_synoptic(self):
"""True if the current hour is a synoptic one."""
return self.hour in (0, 6, 12, 18)
[docs] def strftime(self, *kargs, **kwargs):
rstr = super().strftime(*kargs, **kwargs)
return rstr
@property
def julian(self):
"""Returns Julian day."""
return self.strftime('%j')
@property
def ymd(self):
"""YYYYMMDD formated string."""
return self.strftime('%Y%m%d')
@property
def yymd(self):
"""YYMMDD formated string."""
return self.strftime('%y%m%d')
@property
def y(self):
"""YYYYMMDDHH formated string."""
return self.strftime('%Y')
@property
def ymdh(self):
"""YYYYMMDDHH formated string."""
return self.strftime('%Y%m%d%H')
@property
def yymdh(self):
"""YYMMDDHH formated string."""
return self.strftime('%y%m%d%H')
@property
def ymd6h(self):
"""YYMMDDHH formated string with HH=6:00."""
return self.replace(hour=6).strftime('%Y%m%d%H')
@property
def ymdhm(self):
"""YYYYMMDDHHMM formated string."""
return self.strftime('%Y%m%d%H%M')
@property
def ymdhms(self):
"""YYYYMMDDHHMMSS formated string."""
return self.strftime('%Y%m%d%H%M%S')
@property
def mmddhh(self):
return self.strftime('%m%d%H')
[docs] def stamp(self):
"""Compact concatenation up to microseconds."""
return self.ymdhms + '{:06d}'.format(self.microsecond)
@property
def mm(self):
"""MM (month) formated string."""
return self.strftime('%m')
@property
def hm(self):
"""HHMM formated string."""
return self.strftime('%H%M')
@property
def dd(self):
"""DD formated string."""
return self.strftime('%d')
@property
def hh(self):
"""HH formated string."""
return self.strftime('%H')
@property
def ymdHh(self):
"""SURFEX-SODA hour format yymmddHhh ex : 140331H10"""
return self.strftime('%y%m%dH%H')
@property
def h(self):
"""H formated string."""
return self.strftime('%-H')
[docs] def compact(self):
"""Compact concatenation of date values, up to the second (YYYYMMDDHHSS)."""
return self.ymdhms
[docs] def vortex(self, cutoff='P'):
"""Semi-compact representation for vortex paths."""
return self.strftime('%Y%m%dT%H%M') + str(cutoff)[0].upper()
@property
def stdvortex(self):
"""Semi-compact representation for vortex paths (without cutoff)."""
return self.strftime('%Y%m%dT%H%M')
[docs] def reallynice(self):
"""Nice and verbose string representation."""
return self.strftime("%A %d. %B %Y, at %H:%M:%S")
[docs] def export_dict(self):
"""String representation for dict or shell variable."""
return self.ymdhm
def __add__(self, delta):
"""
Add to a Date object the specified ``delta`` which could be either
a string or a :class:`datetime.timedelta` or an ISO 6801 Period.
"""
if not isinstance(delta, datetime.timedelta):
delta = Period(delta)
return Date(super().__add__(datetime.timedelta(delta.days, delta.seconds)))
def __radd__(self, delta):
"""Reversed add."""
return self.__add__(delta)
def __sub__(self, delta):
"""
Subtract to a Date object the specified ``delta`` which could be either
a string or a :class:`datetime.timedelta` or an ISO 6801 Period.
"""
if not isinstance(delta, datetime.datetime) and not isinstance(delta, datetime.timedelta):
delta = guess(delta)
substract = super().__sub__(delta)
if isinstance(delta, datetime.datetime):
return Period(substract)
else:
return Date(substract)
def __rsub__(self, delta):
"""Reversed-substract (based on the __sub__ method)."""
if not isinstance(delta, self.__class__):
try:
delta = self.__class__(delta)
except (ValueError, TypeError):
raise ValueError("'{:s} cannot be convert to a proper Date object".format(delta))
return delta - self
def __eq__(self, other):
"""Compare two Date values or a Date and a datetime or string value."""
try:
other = self.__class__(other).compact()
except (ValueError, TypeError):
pass
finally:
return self.compact() == '{:<08s}'.format(str(other))
def __ne__(self, other):
return not self.__eq__(other)
def __lt__(self, other):
"""Compare two Date values or a Date and a datetime or string value."""
try:
other = self.__class__(other).compact()
except (ValueError, TypeError):
pass
finally:
return self.compact() < '{:<08s}'.format(str(other))
def __le__(self, other):
return self == other or self < other
def __gt__(self, other):
"""Compare two Date values or a Date and a datetime or string value."""
try:
other = self.__class__(other).compact()
except (ValueError, TypeError):
pass
finally:
return self.compact() > '{:<08s}'.format(str(other))
def __ge__(self, other):
return self == other or self > other
[docs] def replace(self, **kw):
"""Possible arguments: year, month, day, hour, minute."""
for datekey in ('year', 'month', 'day', 'hour', 'minute'):
kw.setdefault(datekey, getattr(self, datekey))
return Date(datetime.datetime(**kw))
@property
def cnes_origin(self):
return datetime.datetime(1950, 1, 1).toordinal()
[docs] def to_cnesjulian(self, date=None):
"""
Convert current Date() object, or arbitrary date, to CNES julian calendar
>>> d = Date('20111026')
>>> d.to_cnesjulian()
22578
>>> d.to_cnesjulian(date=[2011, 10, 27])
22579
"""
if not date:
date = datetime.datetime(self.year, self.month, self.day)
if isinstance(date, list):
date = datetime.datetime(*date)
return date.toordinal() - self.cnes_origin
[docs] def from_cnesjulian(self, jdays=None):
"""
>>> d = Date('20111025')
>>> d.from_cnesjulian()
Date(2011, 10, 25, 0, 0)
>>> d.from_cnesjulian(22578)
Date(2011, 10, 26, 0, 0)
"""
if jdays is None:
jdays = self.toordinal() - self.cnes_origin
return Date(self.fromordinal(jdays + self.cnes_origin))
[docs] def isleap(self, year=None):
"""Return whether the current of specified year is a leap year."""
if year is None:
year = self.year
return calendar.isleap(year)
[docs] def monthrange(self, year=None, month=None):
"""Return the number of days in the current of specified year-month couple."""
if year is None:
year = self.year
if month is None:
month = self.month
return calendar.monthrange(year, month)[1]
[docs] def time(self):
"""Return a :class:`Time` object built from the present object hours and minutes."""
return Time(self.hour, self.minute)
[docs] def bounds(self):
"""Return first and last day of the current month."""
return (
self.replace(day=1, hour=0, minute=0),
self.replace(day=self.monthrange(), hour=23, minute=59)
)
@property
def outbound(self):
"""Return the closest day out of this month."""
a, b = self.bounds()
if self - a > b - self:
out = b + 'P1D'
else:
out = a - 'P1D'
return out.ymd
@property
def midcross(self):
"""Return the closest day out of this month."""
a, b = self.bounds()
if self.day > 15:
out = b + 'P1D'
else:
out = a - 'P1D'
return out.ymd
@property
def nivologyseason_begin(self):
"""Return the begin date of the current nivology season."""
return self.__class__(self.year - 1 if self.month < 8 else self.year, 8, 1, 6, 0)
@property
def nivologyseason(self):
"""Return the nivology season of a current date"""
season_begin = self.nivologyseason_begin
season_end = season_begin + Period('P1Y')
return season_begin.strftime('%y') + season_end.strftime('%y')
[docs]class Time(_GetattrCalculatorMixin):
"""Basic object to handle hh:mm information.
Extended arithmetic is supported.
"""
def __init__(self, *args, **kw):
"""
The object can be constructed from:
* a standard :class:`datetime.time` object;
* a :class:`Time` object
* a :class:`Period` object
* named attributes compatible with the :class:`datetime.time` class;
* a string that could be reshaped as a :class:`Period` object.
* a string that could be reshaped as an ISO 8601 time string.
* two integers (hours, minutes)
* a tuple containing (hour, minute) values;
Here are a few equivalent examples::
>>> Time('18:05')
Time(18, 5)
>>> Time('18h05')
Time(18, 5)
>>> Time('18-05')
Time(18, 5)
>>> Time('T18:05Z')
Time(18, 5)
>>> Time(18, 5)
Time(18, 5)
>>> Time((18, 5))
Time(18, 5)
>>> Time('PT18H05M')
Time(18, 5)
>>> Time(hour=18, minute=5)
Time(18, 5)
When constructed from strings, the :class:`Time` object can handle
negative values::
>>> Time('-18:05')
Time(-18, -5)
>>> Time('-PT18H05M')
Time(-18, -5)
The addition and subtraction is defined (if the operand is not a
:class:`Time` object, a conversion is attempted)::
>>> Time('12:00') + Time('06:30')
Time(18, 30)
>>> Time('12:00') + '06:30'
Time(18, 30)
>>> Time('12:00') + 'PT06H30M'
Time(18, 30)
>>> Time('12:00') - 'PT06H30M'
Time(5, 30)
Let's do some calculations while creating the object::
>>> Time('12:00/PT6H')
Time(18, 0)
>>> Time('-PT12H/-P1D/PT12H')
Time(-24, 0)
Comparison operators are all available.
The :class:`Time` also have the ability to generate dynamic properties
on the fly (because it inherits the :class:`_GetattrCalculatorMixin`
mix in). Such dynamic properties can be very useful when used with
:mod:`footprints` package substitution mechanism.
It allows to do calculations on the fly::
>>> atime = Time('12:00')
>>> atime.add06h30
Time(18, 30)
>>> atime.addPT6H30M
Time(18, 30)
>>> print(atime.addPT6H30M_fmthm)
0018:30
>>> print(atime.subPT18H30M_fmthm)
-0006:30
"""
if kw:
kw.setdefault('hour', 0)
kw.setdefault('minute', 0)
args = (datetime.time(**kw),)
if not args:
raise ValueError("No initial value provided for Time")
top = args[0]
ld = list()
deltas = []
self._hour, self._minute = None, None
if isinstance(top, tuple) or isinstance(top, list):
zz = Time(*top)
self._hour, self._minute = zz.hour, zz.minute
elif isinstance(top, datetime.time) or isinstance(top, Time):
self._hour, self._minute = top.hour, top.minute
elif isinstance(top, TimeInt):
self._hour, self._minute = top.ti, top.tm
elif isinstance(top, Period):
newtime = top.time()
self._hour, self._minute = newtime.hour, newtime.minute
elif isinstance(top, float):
self._hour, self._minute = int(top), int((top - int(top)) * 60)
elif isinstance(top, str):
s_top = top.split('/')
top = s_top[0]
deltas = s_top[1:]
if re.match(r'^[+-]?P', top): # This looks like a Period string...
newtime = Period(top).time()
self._hour, self._minute = newtime.hour, newtime.minute
else:
top = re.sub(r'^(-?):(\d+)$', r'\g<1>0:\g<2>', top)
thesign = -2 * int(bool(re.match(r'^-', top))) + 1
ld = [thesign * int(x) for x in re.split('[-:hHTZ]+', top) if re.match(r'\d+$', x)]
else:
ld = [int(x) for x in args
if (type(x) in (int, float) or
(isinstance(x, str) and re.match(r'\d+$', x)))]
if ld:
if len(ld) < 2:
ld.append(0)
self._hour, self._minute = ld[0], ld[1]
if self._hour is None or self._minute is None:
raise ValueError("No way to build a Time value")
# If minute > 60 do something...
if abs(self._minute) >= 60:
thesign = int(self._minute > 0) * 2 - 1
while abs(self._minute) >= 60:
self._hour += thesign
self._minute -= thesign * 60
# Apply deltas
if deltas:
new_time = self + sum([Period(d) for d in deltas], Period(0))
self._hour = new_time.hour
self._minute = new_time.minute
@property
def hour(self):
"""The number of hours"""
return self._hour
@property
def minute(self):
"""The number of minutes"""
return self._minute
def __deepcopy__(self, memo):
"""Clone of the current :class:`Time` object."""
newinstance = Time(self.hour, self.minute)
memo[id(self)] = newinstance
return newinstance
def __repr__(self):
"""Standard hour-minute representation."""
return 'Time({:d}, {:d})'.format(self.hour, self.minute)
[docs] def export_dict(self):
"""String representation for dict or shell variable."""
return self.__str__()
def _formatted_str(self, fmt):
thesign = '-' if int(self) < 0 else ''
return fmt.format(thesign, abs(self.hour), abs(self.minute))
def __str__(self):
"""Standard hour-minute string (like HH:MM)."""
return self._formatted_str('{0:s}{1:02d}:{2:02d}')
def __int__(self):
"""Convert to `int`, ie: returns hours * 60 + minutes."""
return self._hour * 60 + self._minute
def __hash__(self):
"""Return a hashkey."""
return self.__int__()
def __comparison_prepare(self, other):
try:
other = self.__class__(other)
except (ValueError, TypeError):
pass
return other
def __eq__(self, other):
other = self.__comparison_prepare(other)
try:
return int(self) == int(other)
except (ValueError, TypeError):
return False
def __ne__(self, other):
other = self.__comparison_prepare(other)
try:
return int(self) != int(other)
except (ValueError, TypeError):
return True
def __gt__(self, other):
other = self.__comparison_prepare(other)
return int(self) > int(other)
def __ge__(self, other):
other = self.__comparison_prepare(other)
return int(self) >= int(other)
def __lt__(self, other):
other = self.__comparison_prepare(other)
return int(self) < int(other)
def __le__(self, other):
other = self.__comparison_prepare(other)
return int(self) <= int(other)
def __add__(self, delta):
"""
Add to a Time object the specified ``delta`` which could be either
a string or a :class:`Period` object or an ISO 6801 Period.
"""
delta = self.__class__(delta)
me = int(self) + int(delta)
return self.__class__(0, me)
def __radd__(self, delta):
"""Reversed add."""
return self.__add__(delta)
def __sub__(self, delta):
"""
Subtract to a Time object the specified ``delta`` which could be either
a string or a :class:`Period` object or an ISO 6801 Period.
"""
delta = self.__class__(delta)
me = int(self) - int(delta)
return self.__class__(0, me)
def __rsub__(self, delta):
"""Reversed subtract."""
delta = self.__class__(delta)
me = int(delta) - int(self)
return self.__class__(0, me)
def __mul__(self, other):
# The result might be truncated since second/microseconds are not suported
other = self.__class__(other)
me = (int(self) * int(other)) // 60
return self.__class__(0, me)
def __rmul__(self, other):
return self.__mul__(other)
@property
def dhm(self):
"""Return a tuple with the number of (days, hours, minutes)."""
totalminutes = int(self)
sign = -1 if totalminutes < 0 else 1
totalminutes = abs(totalminutes)
days = totalminutes // 1440
hours = (totalminutes % 1440) // 60
minutes = (totalminutes % 60)
return (sign * days, sign * hours, sign * minutes)
@property
def fmtdhm(self):
"""DDHHMM formated string."""
(days, hours, minutes) = self.dhm
sign = '-' if int(self) < 0 else ''
return '{:s}{:02d}{:02d}{:02d}'.format(sign, abs(days), abs(hours), abs(minutes))
@property
def fmth(self):
"""HHHH formated string."""
return self._formatted_str('{0:s}{1:04d}')
@property
def fmthour(self):
"""HHHH formated string."""
return self.fmth
@property
def fmthm(self):
"""HHHH:MM formated string."""
return self._formatted_str('{0:s}{1:04d}:{2:02d}')
@property
def fmthhmm(self):
"""HH:MM formated string."""
return self._formatted_str('{0:s}{1:02d}{2:02d}')
@property
def fmtraw(self):
"""HHHHMM formated string."""
return self._formatted_str('{0:s}{1:04d}{2:02d}')
@property
def fmtraw2(self):
"""HHHHHHHHMM formated string."""
return self._formatted_str('{0:s}{1:08d}{2:02d}')
@property
def notnull(self):
if self.hour != 0 or self.minute != 0:
return 1
return 0
@property
def isnull(self):
if self.hour == 0 and self.minute == 0:
return 1
return 0
[docs] def iso8601(self):
"""Plain ISO 8601 representation."""
return 'T' + self.isoformat() + 'Z'
[docs] def nice(self, t):
"""Kept for backward compatibility. Plesae do not use."""
return '{:04d}'.format(t)
def _delegate_op_to_timeobj(proxymethods):
def delegate(cls):
def make_proxy_mtd(real_pmethod):
def a_pmethod(self, other):
rv = getattr(self._timeobj, real_pmethod)(other)
if isinstance(rv, Time):
rv = TimeInt(rv)
return rv
a_pmethod.__name__ = real_pmethod
return a_pmethod
for pmethod in proxymethods:
real_pmethod = str('__' + pmethod + '__')
setattr(cls, real_pmethod, make_proxy_mtd(real_pmethod))
return cls
return delegate
[docs]@_delegate_op_to_timeobj(['eq', 'ne', 'lt', 'le', 'gt', 'ge',
'add', 'radd', 'sub', 'rsub', 'mul', 'rmul'])
class TimeInt(int):
"""Extended integer able to handle simple integers or integer plus minutes.
In the later case, the first integer is not limited to 24
"""
def __new__(cls, ti, tm=None):
if tm is None:
if isinstance(ti, Time):
timeobj = ti
else:
timeobj = Time(ti)
else:
timeobj = Time(ti, tm)
obj = int.__new__(cls, timeobj.hour)
obj._timeobj = timeobj
obj._int = timeobj.minute == 0
return obj
@property
def ti(self):
return self._timeobj.hour
@property
def tm(self):
return self._timeobj.minute
def is_int(self):
return self.tm == 0
@property
def str_time(self):
return self._timeobj.fmthm
def __str__(self):
if self.is_int():
return str(self.ti)
else:
return self.str_time
def __hash__(self):
return hash(self._timeobj)
@property
def realtype(self):
return 'int' if self.is_int() else 'time'
@property
def value(self):
return self.ti if self.is_int() else str(self)
[docs]@functools.total_ordering
class Month:
"""
Basic class for handling a month number, according to an explicit or
implicit year.
"""
def __init__(self, *args, **kw):
"""
The object can be constructed from:
* a standard :class:`datetime.datetime` object or a :class:`Date` object;
* a :class:`Month` object;
* named attributes compatible with the :class:`datetime.datetime` class;
* a unique integer representing the Month number (in such a case,
the year is assumed to be the current year);
* a tuple containing two integer representing (month, year) values;
* any string supported by the :class:`Date` object;
Here are a few equivalent examples::
>>> Month(year=2016, month=1, day=1)
Month(01, year=2016)
>>> Month('2016010100')
Month(01, year=2016)
>>> Month(1, 2016)
Month(01, year=2016)
The *year* may not be specified (in such a case the current year is used)::
>>> Month(1) # doctest: +SKIP
Month(01, year=2017)
When initialising the object with a string, some nice helpers are provided::
>>> Month('2016010100:prev')
Month(12, year=2015)
>>> Month('2016010100:next')
Month(02, year=2016)
>>> Month('2016011500:closest')
Month(12, year=2015)
>>> Month('2016011600:closest')
Month(02, year=2016)
Concerted to an integer, this object returns the month number::
>>> int(Month('2016010100'))
1
The addition and subtraction is defined (the operand can be an integer,
a :class:`Period` object, or a string that can be converted to a
:class:`Period` object)::
>>> Month('2016010100') + 1
Month(02, year=2016)
>>> Month('2016010100') + Period('P2M')
Month(03, year=2016)
>>> Month('2016010100') + 'P2M'
Month(03, year=2016)
>>> Month('2016010100') - 'P2M'
Month(10, year=2015)
Some kind of comparisons are possible::
>>> Month('2016010100') == 1
True
>>> Month('2016010100') < 2
True
>>> Month('2016010100') > 1
False
>>> Month('2016010100') > Month('2015100100')
True
"""
delta = kw.pop('delta', 0)
try:
args = (datetime.datetime(**kw),)
except (ValueError, TypeError):
pass
if not args:
raise ValueError("No initial value provided for Month")
args = list(args)
top = args[0]
self._month = None
self._year = max(0, int(kw.pop('year', today().year)))
if isinstance(top, datetime.datetime) or isinstance(top, Month):
self._month, self._year = top.month, top.year
elif isinstance(top, int) and 0 < top < 13:
self._month = top
if len(args) == 2:
self._year = int(args[1])
else:
# Try to generate a Date object
mmod = None
if isinstance(top, str):
mmod = re.search(':(next|prev|closest)$', top)
if mmod:
args[0] = re.sub(':(?:next|prev|closest)$', '', top)
try:
tmpdate = Date(*args)
except (ValueError, TypeError):
try:
self._month = int(args[0])
if not (1 <= self._month <= 12):
raise ValueError('Not a valid month: {}'.format(self._month))
tmpday = 1
except (ValueError, TypeError):
raise ValueError('Could not create a Month from values provided {!s}'.format(args))
else:
self._month, self._year, tmpday = tmpdate.month, tmpdate.year, tmpdate.day
# Process the modifiers
if mmod:
if mmod.group(1) == 'next':
delta = 1
elif mmod.group(1) == 'prev':
delta = -1
elif mmod.group(1) == 'closest':
if tmpday > 15:
delta = 1
else:
delta = -1
# If present, the second argument is the delta (it overrides the modifiers)
if len(args) == 2:
delta = args.pop()
if delta:
mtmp = self + delta
self._month, self._year = mtmp.month, mtmp.year
@property
def year(self):
"""The year number."""
return self._year
@property
def month(self):
"""The month number."""
return self._month
@property
def fmtym(self):
"""YYYY-MM formated string."""
return '{:04d}-{:02d}'.format(self._year, self._month)
@property
def fmtraw(self):
"""YYYYMM formated string."""
return '{:04d}{:02d}'.format(self._year, self._month)
[docs] def export_dict(self):
"""Return the month and year as a tuple."""
return (self.month, self.year)
[docs] def nextmonth(self):
"""Return the month after the current one."""
return self + 1
[docs] def prevmonth(self):
"""Return the month before the current one."""
return self - 1
def __hash__(self):
return hash((self._year, self._month))
def __str__(self):
"""Return a two digit value of the current month int value."""
return '{:02d}'.format(self._month)
def __repr__(self):
"""Return a formated id of the current month."""
return '{:s}({:02d}, year={:d})'.format(self.__class__.__name__, self._month, self._year)
def __add__(self, delta):
"""
Add to a Month object the specified ``delta`` which could be either
an integer (number of months), a :class:`Period` object, or a string
that can be converted to a :class:`Period` object.
"""
if isinstance(delta, int):
if delta < 0:
incr = -1
delta = abs(delta)
else:
incr = 1
year, month = self._year, self._month
while delta:
month += incr
if month > 12:
year += 1
month = 1
if month < 1:
year -= 1
month = 12
delta -= 1
if self._year == 0:
year = 0
return Month(month, year)
elif not isinstance(delta, datetime.timedelta):
delta = Period(delta)
return Month(Date(self._year, self._month, 14) + delta)
def __radd__(self, delta):
"""Commutative add."""
return self.__add__(delta)
def __sub__(self, delta):
"""
Subtract to a Month object the specified ``delta`` which could be either
an integer (number of months), a :class:`Period` object , or a string
that can be converted to a :class:`Period` object
"""
if isinstance(delta, int):
return self.__add__(-1 * delta)
elif not isinstance(delta, datetime.timedelta):
delta = Period(delta)
return Month(Date(self._year, self._month, 1) - delta)
def __int__(self):
return self._month
def __eq__(self, other):
try:
if isinstance(other, int) or (isinstance(other, str) and
len(other.lstrip('0')) < 3):
rc = self.month == Month(int(other), self.year).month
else:
if isinstance(other, tuple) or isinstance(other, list):
mtest = Month(*other)
else:
mtest = Month(other)
if self.year * mtest.year == 0:
rc = self.month == mtest.month
else:
rc = self.fmtym == mtest.fmtym
except (ValueError, TypeError):
rc = False
finally:
return rc
def __gt__(self, other):
if isinstance(other, int) or (isinstance(other, str) and
len(other.lstrip('0')) < 3):
rc = self.month > Month(int(other), self.year).month
else:
if isinstance(other, tuple) or isinstance(other, list):
mtest = Month(*other)
else:
mtest = Month(other)
if self.year * mtest.year == 0:
rc = self.month > mtest.month
else:
rc = self.fmtym > mtest.fmtym
return rc
if __name__ == '__main__':
import doctest
doctest.testmod()