import datetime
import re
from typing import Union, Tuple, Optional, List
DateIsh = Union[datetime.date, datetime.datetime]
def total_seconds(td):
"""Equivalent to `datetime.timedelta.total_seconds`."""
return float(td.microseconds +
(td.seconds + td.days * 24 * 3600) * 10 ** 6) / 10 ** 6
def total_minutes(td):
"""Alias for ``total_seconds(td) / 60``."""
return total_seconds(td) / 60
def gene_timestamp_regex(brtype, prefix=None, nocookie=False):
"""
Generate timestamp regex for active/inactive/nobrace brace type
:type brtype: {'active', 'inactive', 'nobrace'}
:arg brtype:
It specifies a type of brace.
active: <>-type; inactive: []-type; nobrace: no braces.
:type prefix: str or None
:arg prefix:
It will be appended to the head of keys of the "groupdict".
For example, if prefix is ``'active_'`` the groupdict has
keys such as ``'active_year'``, ``'active_month'``, and so on.
If it is None it will be set to ``brtype`` + ``'_'``.
:type nocookie: bool
:arg nocookie:
Cookie part (e.g., ``'-3d'`` or ``'+6m'``) is not included if
it is ``True``. Default value is ``False``.
>>> timestamp_re = re.compile(
... gene_timestamp_regex('active', prefix=''),
... re.VERBOSE)
>>> timestamp_re.match('no match') # returns None
>>> m = timestamp_re.match('<2010-06-21 Mon>')
>>> m.group()
'<2010-06-21 Mon>'
>>> '{year}-{month}-{day}'.format(**m.groupdict())
'2010-06-21'
>>> m = timestamp_re.match('<2005-10-01 Sat 12:30 +7m -3d>')
>>> from collections import OrderedDict
>>> sorted(m.groupdict().items())
... # doctest: +NORMALIZE_WHITESPACE
[('day', '01'),
('end_hour', None), ('end_min', None),
('hour', '12'), ('min', '30'),
('month', '10'),
('repeatdwmy', 'm'), ('repeatnum', '7'), ('repeatpre', '+'),
('warndwmy', 'd'), ('warnnum', '3'), ('warnpre', '-'), ('year', '2005')]
When ``brtype = 'nobrace'``, cookie part cannot be retrieved.
>>> timestamp_re = re.compile(
... gene_timestamp_regex('nobrace', prefix=''),
... re.VERBOSE)
>>> timestamp_re.match('no match') # returns None
>>> m = timestamp_re.match('2010-06-21 Mon')
>>> m.group()
'2010-06-21'
>>> '{year}-{month}-{day}'.format(**m.groupdict())
'2010-06-21'
>>> m = timestamp_re.match('2005-10-01 Sat 12:30 +7m -3d')
>>> sorted(m.groupdict().items())
... # doctest: +NORMALIZE_WHITESPACE
[('day', '01'),
('end_hour', None), ('end_min', None),
('hour', '12'), ('min', '30'),
('month', '10'), ('year', '2005')]
"""
if brtype == 'active':
(bo, bc) = ('<', '>')
elif brtype == 'inactive':
(bo, bc) = (r'\[', r'\]')
elif brtype == 'nobrace':
(bo, bc) = ('', '')
else:
raise ValueError("brtype='{0!r}' is invalid".format(brtype))
if brtype == 'nobrace':
ignore = r'[\s\w]'
else:
ignore = '[^{bc}]'.format(bc=bc)
if prefix is None:
prefix = '{0}_'.format(brtype)
regex_date_time = r"""
(?P<{prefix}year>\d{{4}}) -
(?P<{prefix}month>\d{{2}}) -
(?P<{prefix}day>\d{{2}})
( # optional time field
({ignore}+?)
(?P<{prefix}hour>\d{{2}}) :
(?P<{prefix}min>\d{{2}})
( # optional end time range
--?
(?P<{prefix}end_hour>\d{{2}}) :
(?P<{prefix}end_min>\d{{2}})
)?
)?
"""
regex_cookie = r"""
( # optional repeater
({ignore}+?)
(?P<{prefix}repeatpre> [\.\+]{{1,2}})
(?P<{prefix}repeatnum> \d+)
(?P<{prefix}repeatdwmy> [hdwmy])
)?
( # optional warning
({ignore}+?)
(?P<{prefix}warnpre> \-)
(?P<{prefix}warnnum> \d+)
(?P<{prefix}warndwmy> [hdwmy])
)?
"""
# http://www.pythonregex.com/
regex = ''.join([
bo,
regex_date_time,
regex_cookie if nocookie or brtype != 'nobrace' else '',
'({ignore}*?)',
bc])
return regex.format(prefix=prefix, ignore=ignore)
def date_time_format(date) -> str:
"""
Format a date or datetime in default org format
@param date The date
@return Formatted date(time)
"""
default_format_date = "%Y-%m-%d %a"
default_format_datetime = "%Y-%m-%d %a %H:%M"
is_datetime = isinstance(date, datetime.datetime)
return date.strftime(default_format_datetime if is_datetime else default_format_date)
def is_same_day(date0, date1) -> bool:
"""
Check if two dates or datetimes are on the same day
"""
return (OrgDate._date_to_tuple(date0)[:3] == OrgDate._date_to_tuple(date1)[:3])
TIMESTAMP_NOBRACE_RE = re.compile(
gene_timestamp_regex('nobrace', prefix=''),
re.VERBOSE)
TIMESTAMP_RE = re.compile(
'|'.join((gene_timestamp_regex('active'),
gene_timestamp_regex('inactive'))),
re.VERBOSE)
class OrgDate(object):
_active_default = True
"""
The default active value.
When the `active` argument to ``__init__`` is ``None``,
This value will be used.
"""
"""
When formatting the date to string via __str__, and there is an end date on
the same day as the start date, allow formatting in the short syntax
<2021-09-03 Fri 16:01--17:30>? Otherwise the string represenation would be
<2021-09-03 Fri 16:01>--<2021-09-03 Fri 17:30>
"""
_allow_short_range = True
def __init__(self, start, end=None, active=None, repeater=None,
warning=None):
"""
Create :class:`OrgDate` object
:type start: datetime, date, tuple, int, float or None
:type end: datetime, date, tuple, int, float or None
:arg start: Starting date.
:arg end: Ending date.
:type active: bool or None
:arg active: Active/inactive flag.
None means using its default value, which
may be different for different subclasses.
:type repeater: tuple or None
:arg repeater: Repeater interval.
:type warning: tuple or None
:arg warning: Deadline warning interval.
>>> OrgDate(datetime.date(2012, 2, 10))
OrgDate((2012, 2, 10))
>>> OrgDate((2012, 2, 10))
OrgDate((2012, 2, 10))
>>> OrgDate((2012, 2)) #doctest: +NORMALIZE_WHITESPACE
Traceback (most recent call last):
...
ValueError: Automatic conversion to the datetime object
requires at least 3 elements in the tuple.
Only 2 elements are in the given tuple '(2012, 2)'.
>>> OrgDate((2012, 2, 10, 12, 20, 30))
OrgDate((2012, 2, 10, 12, 20, 30))
>>> OrgDate((2012, 2, 10), (2012, 2, 15), active=False)
OrgDate((2012, 2, 10), (2012, 2, 15), False)
OrgDate can be created using unix timestamp:
>>> OrgDate(datetime.datetime.fromtimestamp(0)) == OrgDate(0)
True
"""
self._start = self._to_date(start)
self._end = self._to_date(end)
self._active = self._active_default if active is None else active
# repeater and warning are tuples of (prefix, number, interval)
self._repeater = repeater
self._warning = warning
@staticmethod
def _to_date(date) -> DateIsh:
if isinstance(date, (tuple, list)):
if len(date) == 3:
return datetime.date(*date)
elif len(date) > 3:
return datetime.datetime(*date)
else:
raise ValueError(
"Automatic conversion to the datetime object "
"requires at least 3 elements in the tuple. "
"Only {0} elements are in the given tuple '{1}'."
.format(len(date), date))
elif isinstance(date, (int, float)):
return datetime.datetime.fromtimestamp(date)
else:
return date
@staticmethod
def _date_to_tuple(date):
if isinstance(date, datetime.datetime):
return tuple(date.timetuple()[:6])
elif isinstance(date, datetime.date):
return tuple(date.timetuple()[:3])
def __repr__(self):
args = [
self.__class__.__name__,
self._date_to_tuple(self.start),
self._date_to_tuple(self.end) if self.has_end() else None,
None if self._active is self._active_default else self._active,
self._repeater,
self._warning,
]
while args[-1] is None:
args.pop()
if len(args) > 3 and args[3] is None:
args[3] = self._active_default
return '{0}({1})'.format(args[0], ', '.join(map(repr, args[1:])))
def __str__(self):
fence = ("<", ">") if self.is_active() else ("[", "]")
start = date_time_format(self.start)
end = None
if self.has_end():
if self._allow_short_range and is_same_day(self.start, self.end):
start += "--%s" % self.end.strftime("%H:%M")
else:
end = date_time_format(self.end)
if self._repeater:
start += " %s%d%s" % self._repeater
if self._warning:
start += " %s%d%s" % self._warning
ret = "%s%s%s" % (fence[0], start, fence[1])
if end:
ret += "--%s%s%s" % (fence[0], end, fence[1])
return ret
def __nonzero__(self):
return bool(self._start)
__bool__ = __nonzero__ # PY3
def __eq__(self, other):
if (isinstance(other, OrgDate) and
self._start is None and
other._start is None):
return True
return (isinstance(other, self.__class__) and
self._start == other._start and
self._end == other._end and
self._active == other._active)
@property
def start(self):
"""
Get date or datetime object
>>> OrgDate((2012, 2, 10)).start
datetime.date(2012, 2, 10)
>>> OrgDate((2012, 2, 10, 12, 10)).start
datetime.datetime(2012, 2, 10, 12, 10)
"""
return self._start
@property
def end(self):
"""
Get date or datetime object
>>> OrgDate((2012, 2, 10), (2012, 2, 15)).end
datetime.date(2012, 2, 15)
>>> OrgDate((2012, 2, 10, 12, 10), (2012, 2, 15, 12, 10)).end
datetime.datetime(2012, 2, 15, 12, 10)
"""
return self._end
def is_active(self) -> bool:
"""Return true if the date is active"""
return self._active
def has_end(self) -> bool:
"""Return true if it has the end date"""
return bool(self._end)
def has_time(self) -> bool:
"""
Return true if the start date has time field
>>> OrgDate((2012, 2, 10)).has_time()
False
>>> OrgDate((2012, 2, 10, 12, 10)).has_time()
True
"""
return isinstance(self._start, datetime.datetime)
def has_overlap(self, other) -> bool:
"""
Test if it has overlap with other :class:`OrgDate` instance
If the argument is not an instance of :class:`OrgDate`, it is
converted to :class:`OrgDate` instance by ``OrgDate(other)``
first.
>>> od = OrgDate((2012, 2, 10), (2012, 2, 15))
>>> od.has_overlap(OrgDate((2012, 2, 11)))
True
>>> od.has_overlap(OrgDate((2012, 2, 20)))
False
>>> od.has_overlap(OrgDate((2012, 2, 11), (2012, 2, 20)))
True
>>> od.has_overlap((2012, 2, 11))
True
"""
if not isinstance(other, OrgDate):
other = OrgDate(other)
if self.has_end():
return (self._datetime_in_range(other.start) or
self._datetime_in_range(other.end))
elif other.has_end():
return other._datetime_in_range(self.start)
elif self.start == other.get_start:
return True
else:
return False
def _datetime_in_range(self, date):
if not isinstance(date, (datetime.datetime, datetime.date)):
return False
asdt = self._as_datetime
if asdt(self.start) <= asdt(date) <= asdt(self.end):
return True
return False
@staticmethod
def _as_datetime(date) -> datetime.datetime:
"""
Convert the given date into datetime (if it already is, return it
unmodified
"""
if not isinstance(date, datetime.datetime):
return datetime.datetime(*date.timetuple()[:3])
return date
@staticmethod
def _daterange_from_groupdict(dct, prefix='') -> Tuple[List, Optional[List]]:
start_keys = ['year', 'month', 'day', 'hour' , 'min']
end_keys = ['year', 'month', 'day', 'end_hour', 'end_min']
start_range = list(map(int, filter(None, (dct[prefix + k] for k in start_keys))))
end_range: Optional[List]
end_range = list(map(int, filter(None, (dct[prefix + k] for k in end_keys))))
if len(end_range) < len(end_keys):
end_range = None
return (start_range, end_range)
@classmethod
def _datetuple_from_groupdict(cls, dct, prefix=''):
return cls._daterange_from_groupdict(dct, prefix=prefix)[0]
@classmethod
def list_from_str(cls, string: str) -> List['OrgDate']:
"""
Parse string and return a list of :class:`OrgDate` objects
>>> OrgDate.list_from_str("... <2012-02-10 Fri> and <2012-02-12 Sun>")
[OrgDate((2012, 2, 10)), OrgDate((2012, 2, 12))]
>>> OrgDate.list_from_str("<2012-02-10 Fri>--<2012-02-12 Sun>")
[OrgDate((2012, 2, 10), (2012, 2, 12))]
>>> OrgDate.list_from_str("<2012-02-10 Fri>--[2012-02-12 Sun]")
[OrgDate((2012, 2, 10)), OrgDate((2012, 2, 12), None, False)]
>>> OrgDate.list_from_str("this is not timestamp")
[]
>>> OrgDate.list_from_str("<2012-02-11 Sat 10:11--11:20>")
[OrgDate((2012, 2, 11, 10, 11, 0), (2012, 2, 11, 11, 20, 0))]
"""
cookie_suffix = ['pre', 'num', 'dwmy']
match = TIMESTAMP_RE.search(string)
if match:
rest = string[match.end():]
mdict = match.groupdict()
if mdict['active_year']:
prefix = 'active_'
active = True
rangedash = '--<'
else:
prefix = 'inactive_'
active = False
rangedash = '--['
repeater: Optional[Tuple[str, int, str]] = None
warning: Optional[Tuple[str, int, str]] = None
if mdict[prefix + 'repeatpre'] is not None:
keys = [prefix + 'repeat' + suffix for suffix in cookie_suffix]
values = [mdict[k] for k in keys]
repeater = (values[0], int(values[1]), values[2])
if mdict[prefix + 'warnpre'] is not None:
keys = [prefix + 'warn' + suffix for suffix in cookie_suffix]
values = [mdict[k] for k in keys]
warning = (values[0], int(values[1]), values[2])
has_rangedash = rest.startswith(rangedash)
match2 = TIMESTAMP_RE.search(rest) if has_rangedash else None
if has_rangedash and match2:
rest = rest[match2.end():]
# no need for check activeness here because of the rangedash
mdict2 = match2.groupdict()
odate = cls(
cls._datetuple_from_groupdict(mdict, prefix),
cls._datetuple_from_groupdict(mdict2, prefix),
active=active, repeater=repeater, warning=warning)
else:
odate = cls(
*cls._daterange_from_groupdict(mdict, prefix),
active=active, repeater=repeater, warning=warning)
return [odate] + cls.list_from_str(rest)
else:
return []
@classmethod
def from_str(cls, string):
"""
Parse string and return an :class:`OrgDate` objects.
>>> OrgDate.from_str('2012-02-10 Fri')
OrgDate((2012, 2, 10))
>>> OrgDate.from_str('2012-02-10 Fri 12:05')
OrgDate((2012, 2, 10, 12, 5, 0))
"""
match = cls._from_str_re.match(string)
if match:
mdict = match.groupdict()
return cls(cls._datetuple_from_groupdict(mdict),
active=cls._active_default)
else:
return cls(None)
_from_str_re = TIMESTAMP_NOBRACE_RE
def compile_sdc_re(sdctype):
brtype = 'inactive' if sdctype == 'CLOSED' else 'active'
return re.compile(
r'^(?!\#).*{0}:\s+{1}'.format(
sdctype,
gene_timestamp_regex(brtype, prefix='', nocookie=True)),
re.VERBOSE)
class OrgDateSDCBase(OrgDate):
_re = None # override this!
# FIXME: use OrgDate.from_str
@classmethod
def from_str(cls, string):
rgx = cls._re
assert rgx is not None
match = rgx.search(string)
if match:
mdict = match.groupdict()
start = cls._datetuple_from_groupdict(mdict)
end = None
end_hour = mdict['end_hour']
end_min = mdict['end_min']
if end_hour is not None and end_min is not None:
end_dict = {}
end_dict.update(mdict)
end_dict.update({'hour': end_hour, 'min': end_min})
end = cls._datetuple_from_groupdict(end_dict)
cookie_suffix = ['pre', 'num', 'dwmy']
repeater: Optional[Tuple[str, int, str]] = None
warning: Optional[Tuple[str, int, str]] = None
prefix = ''
if mdict[prefix + 'repeatpre'] is not None:
keys = [prefix + 'repeat' + suffix for suffix in cookie_suffix]
values = [mdict[k] for k in keys]
repeater = (values[0], int(values[1]), values[2])
if mdict[prefix + 'warnpre'] is not None:
keys = [prefix + 'warn' + suffix for suffix in cookie_suffix]
values = [mdict[k] for k in keys]
warning = (values[0], int(values[1]), values[2])
return cls(start, end, active=cls._active_default,
repeater=repeater, warning=warning)
else:
return cls(None)
class OrgDateScheduled(OrgDateSDCBase):
"""Date object to represent SCHEDULED attribute."""
_re = compile_sdc_re('SCHEDULED')
_active_default = True
class OrgDateDeadline(OrgDateSDCBase):
"""Date object to represent DEADLINE attribute."""
_re = compile_sdc_re('DEADLINE')
_active_default = True
class OrgDateClosed(OrgDateSDCBase):
"""Date object to represent CLOSED attribute."""
_re = compile_sdc_re('CLOSED')
_active_default = False
def parse_sdc(string):
return (OrgDateScheduled.from_str(string),
OrgDateDeadline.from_str(string),
OrgDateClosed.from_str(string))
class OrgDateClock(OrgDate):
"""
Date object to represent CLOCK attributes.
>>> OrgDateClock.from_str(
... 'CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] => 0:30')
OrgDateClock((2010, 8, 8, 17, 0, 0), (2010, 8, 8, 17, 30, 0))
"""
_active_default = False
_allow_short_range = False
def __init__(self, start, end=None, duration=None, active=None):
"""
Create OrgDateClock object
"""
super(OrgDateClock, self).__init__(start, end, active=active)
self._duration = duration
@property
def duration(self):
"""
Get duration of CLOCK.
>>> duration = OrgDateClock.from_str(
... 'CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] => 0:30'
... ).duration
>>> duration.seconds
1800
>>> total_minutes(duration)
30.0
"""
return self.end - self.start
def is_duration_consistent(self):
"""
Check duration value of CLOCK line.
>>> OrgDateClock.from_str(
... 'CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] => 0:30'
... ).is_duration_consistent()
True
>>> OrgDateClock.from_str(
... 'CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] => 0:15'
... ).is_duration_consistent()
False
"""
return (self._duration is None or
self._duration == total_minutes(self.duration))
@classmethod
def from_str(cls, line: str) -> 'OrgDateClock':
"""
Get CLOCK from given string.
Return three tuple (start, stop, length) which is datetime object
of start time, datetime object of stop time and length in minute.
"""
match = cls._re.search(line)
if not match:
return cls(None, None)
ymdhm1 = [int(d) for d in match.groups()[:5]]
# second part starting with "--", does not exist for open clock dates
has_end = bool(match.group(6))
ymdhm2_dt: Optional[datetime.datetime]
len_min: Optional[int]
if has_end:
ymdhm2 = [int(d) for d in match.groups()[6:11]]
hm3 = [int(d) for d in match.groups()[11:]]
ymdhm2_dt = datetime.datetime(*ymdhm2) # type: ignore[arg-type]
len_min = hm3[0] * 60 + hm3[1]
else:
ymdhm2_dt = None
len_min = None
return cls(
datetime.datetime(*ymdhm1), # type: ignore[arg-type]
ymdhm2_dt,
len_min,
)
_re = re.compile(
r'^(?!#).*CLOCK:\s+'
r'\[(\d+)\-(\d+)\-(\d+)[^\]\d]*(\d+)\:(\d+)\]'
r'(--\[(\d+)\-(\d+)\-(\d+)[^\]\d]*(\d+)\:(\d+)\]\s+=>\s+(\d+)\:(\d+))?'
)
class OrgDateRepeatedTask(OrgDate):
"""
Date object to represent repeated tasks.
"""
_active_default = False
def __init__(self, start, before, after, active=None):
super(OrgDateRepeatedTask, self).__init__(start, active=active)
self._before = before
self._after = after
def __repr__(self):
args = [self._date_to_tuple(self.start), self.before, self.after]
if self._active is not self._active_default:
args.append(self._active)
return '{0}({1})'.format(
self.__class__.__name__, ', '.join(map(repr, args)))
def __eq__(self, other):
return super(OrgDateRepeatedTask, self).__eq__(other) and \
isinstance(other, self.__class__) and \
self._before == other._before and \
self._after == other._after
@property
def before(self):
"""
The state of task before marked as done.
>>> od = OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE')
>>> od.before
'TODO'
"""
return self._before
@property
def after(self):
"""
The state of task after marked as done.
>>> od = OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE')
>>> od.after
'DONE'
"""
return self._after