This repository has no description
Something went wrong. Try again.
24 kB · 739 lines
Python
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740from __future__ import annotations
import datetimeimport refrom datetime import timedeltafrom typing import Optional, Union
DateIsh = Union[datetime.date, datetime.datetime]
def total_seconds(td: timedelta) -> float: """Equivalent to `datetime.timedelta.total_seconds`.""" return float(td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**6
def total_minutes(td: timedelta) -> float: """Alias for ``total_seconds(td) / 60``.""" return total_seconds(td) / 60
def gene_timestamp_regex(brtype: str, prefix: str | None = None, *, nocookie: bool = False) -> str: """ 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(f"brtype='{brtype!r}' is invalid")
if brtype == 'nobrace': ignore = r'[\s\w]' else: ignore = f'[^{bc}]'
if prefix is None: prefix = f'{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]) )? """ regex = ''.join([ bo, regex_date_time, regex_cookie if nocookie or brtype != 'nobrace' else '', '({ignore}*?)', bc, ]) # fmt: skip return regex.format(prefix=prefix, ignore=ignore)
def date_time_format(date: DateIsh) -> 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,) # fmt: skip
_Repeater = tuple[str, int, str]
class OrgDate: _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: bool | None = None, # noqa: FBT001 repeater: _Repeater | None = None, warning: _Repeater | None = None, ) -> 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. " f"Only {len(date)} elements are in the given tuple '{date}'." ) elif isinstance(date, (int, float)): return datetime.datetime.fromtimestamp(date) else: return date
@staticmethod def _date_to_tuple(date: DateIsh) -> tuple[int, ...]: if isinstance(date, datetime.datetime): return tuple(date.timetuple()[:6]) elif isinstance(date, datetime.date): return tuple(date.timetuple()[:3]) else: raise TypeError(f"can't happen: {date} {type(date)}")
def __repr__(self) -> str: 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 '{}({})'.format(args[0], ', '.join(map(repr, args[1:])))
def __str__(self) -> str: 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 += "--{}".format(self.end.strftime("%H:%M")) else: end = date_time_format(self.end)
if self._repeater is not None: (x, y, z) = self._repeater start += f" {x}{y}{z}" if self._warning is not None: (x, y, z) = self._warning start += f" {x}{y}{z}" ret = f"{fence[0]}{start}{fence[1]}" if end: ret += f"--{fence[0]}{end}{fence[1]}"
return ret
def __bool__(self) -> bool: return bool(self._start)
def __hash__(self) -> int: return hash((self._start, self._end, self._active, self._repeater, self._warning))
def __eq__(self, other) -> bool: 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) -> DateIsh: """ 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) -> DateIsh: """ 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.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'] # fmt: skip end_keys = ['year', 'month', 'day', 'end_hour', 'end_min'] # fmt: skip 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: str) -> OrgDate: """ 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'^(?!\#).*{}:\s+{}'.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().__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: str, after: str, active=None) -> None: super().__init__(start, active=active) self._before = before self._after = after
def __repr__(self) -> str: args: list = [self._date_to_tuple(self.start), self.before, self.after] if self._active is not self._active_default: args.append(self._active) return '{}({})'.format(self.__class__.__name__, ', '.join(map(repr, args)))
def __hash__(self) -> int: return hash((self._before, self._after))
def __eq__(self, other) -> bool: return ( super().__eq__(other) and isinstance(other, self.__class__) and self._before == other._before and self._after == other._after )
@property def before(self) -> str: """ 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) -> str: """ The state of task after marked as done.
>>> od = OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE') >>> od.after 'DONE'
""" return self._after