diff options
Diffstat (limited to '.venv/lib/python3.12/site-packages/orgparse')
25 files changed, 3415 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/orgparse/__init__.py b/.venv/lib/python3.12/site-packages/orgparse/__init__.py new file mode 100644 index 00000000..416a3b7c --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/__init__.py @@ -0,0 +1,166 @@ +# Import README.rst using cog +# [[[cog +# from cog import out +# out('"""\n{0}\n"""'.format(open('../README.rst').read())) +# ]]] +""" +=========================================================== + orgparse - Python module for reading Emacs org-mode files +=========================================================== + + +* `Documentation (Read the Docs) <https://orgparse.readthedocs.org>`_ +* `Repository (at GitHub) <https://github.com/karlicoss/orgparse>`_ +* `PyPI <https://pypi.python.org/pypi/orgparse>`_ + +Install +------- + + pip install orgparse + + +Usage +----- + +There are pretty extensive doctests if you're interested in some specific method. Otherwise here are some example snippets: + + +Load org node +^^^^^^^^^^^^^ +:: + + from orgparse import load, loads + + load('PATH/TO/FILE.org') + load(file_like_object) + + loads(''' + * This is org-mode contents + You can load org object from string. + ** Second header + ''') + + +Traverse org tree +^^^^^^^^^^^^^^^^^ + +>>> root = loads(''' +... * Heading 1 +... ** Heading 2 +... *** Heading 3 +... ''') +>>> for node in root[1:]: # [1:] for skipping root itself +... print(node) +* Heading 1 +** Heading 2 +*** Heading 3 +>>> h1 = root.children[0] +>>> h2 = h1.children[0] +>>> h3 = h2.children[0] +>>> print(h1) +* Heading 1 +>>> print(h2) +** Heading 2 +>>> print(h3) +*** Heading 3 +>>> print(h2.get_parent()) +* Heading 1 +>>> print(h3.get_parent(max_level=1)) +* Heading 1 + + +Accessing node attributes +^^^^^^^^^^^^^^^^^^^^^^^^^ + +>>> root = loads(''' +... * DONE Heading :TAG: +... CLOSED: [2012-02-26 Sun 21:15] SCHEDULED: <2012-02-26 Sun> +... CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 +... :PROPERTIES: +... :Effort: 1:00 +... :OtherProperty: some text +... :END: +... Body texts... +... ''') +>>> node = root.children[0] +>>> node.heading +'Heading' +>>> node.scheduled +OrgDateScheduled((2012, 2, 26)) +>>> node.closed +OrgDateClosed((2012, 2, 26, 21, 15, 0)) +>>> node.clock +[OrgDateClock((2012, 2, 26, 21, 10, 0), (2012, 2, 26, 21, 15, 0))] +>>> bool(node.deadline) # it is not specified +False +>>> node.tags == set(['TAG']) +True +>>> node.get_property('Effort') +60 +>>> node.get_property('UndefinedProperty') # returns None +>>> node.get_property('OtherProperty') +'some text' +>>> node.body +' Body texts...' + +""" +# [[[end]]] + +from io import IOBase +from pathlib import Path +from typing import Iterable, Union, Optional, TextIO + + +from .node import parse_lines, OrgEnv, OrgNode # todo basenode?? + +__all__ = ["load", "loads", "loadi"] + + +def load(path: Union[str, Path, TextIO], env: Optional[OrgEnv] = None) -> OrgNode: + """ + Load org-mode document from a file. + + :type path: str or file-like + :arg path: Path to org file or file-like object of an org document. + + :rtype: :class:`orgparse.node.OrgRootNode` + + """ + # Make sure it is a Path object. + if isinstance(path, str): + path = Path(path) + + # if it is a Path + if isinstance(path, Path): + # open that Path + with path.open('r', encoding='utf8') as orgfile: + # try again loading + return load(orgfile, env) + + # We assume it is a file-like object (e.g. io.StringIO) + all_lines = (line.rstrip('\n') for line in path) + + # get the filename + filename = path.name if hasattr(path, 'name') else '<file-like>' + + return loadi(all_lines, filename=filename, env=env) + + +def loads(string: str, filename: str='<string>', env: Optional[OrgEnv]=None) -> OrgNode: + """ + Load org-mode document from a string. + + :rtype: :class:`orgparse.node.OrgRootNode` + + """ + return loadi(string.splitlines(), filename=filename, env=env) + + +def loadi(lines: Iterable[str], filename: str='<lines>', env: Optional[OrgEnv]=None) -> OrgNode: + """ + Load org-mode document from an iterative object. + + :rtype: :class:`orgparse.node.OrgRootNode` + + """ + return parse_lines(lines, filename=filename, env=env) diff --git a/.venv/lib/python3.12/site-packages/orgparse/date.py b/.venv/lib/python3.12/site-packages/orgparse/date.py new file mode 100644 index 00000000..dd407b78 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/date.py @@ -0,0 +1,717 @@ +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 diff --git a/.venv/lib/python3.12/site-packages/orgparse/extra.py b/.venv/lib/python3.12/site-packages/orgparse/extra.py new file mode 100644 index 00000000..cd51abaf --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/extra.py @@ -0,0 +1,116 @@ +import re +from typing import List, Sequence, Dict, Iterator, Iterable, Union, Optional, Type + + +RE_TABLE_SEPARATOR = re.compile(r'\s*\|(\-+\+)*\-+\|') +RE_TABLE_ROW = re.compile(r'\s*\|([^|]+)+\|') +STRIP_CELL_WHITESPACE = True + + +Row = Sequence[str] + +class Table: + def __init__(self, lines: List[str]) -> None: + self._lines = lines + + @property + def blocks(self) -> Iterator[Sequence[Row]]: + group: List[Row] = [] + first = True + for r in self._pre_rows(): + if r is None: + if not first or len(group) > 0: + yield group + first = False + group = [] + else: + group.append(r) + if len(group) > 0: + yield group + + def __iter__(self) -> Iterator[Row]: + return self.rows + + @property + def rows(self) -> Iterator[Row]: + for r in self._pre_rows(): + if r is not None: + yield r + + def _pre_rows(self) -> Iterator[Optional[Row]]: + for l in self._lines: + if RE_TABLE_SEPARATOR.match(l): + yield None + else: + pr = l.strip().strip('|').split('|') + if STRIP_CELL_WHITESPACE: + pr = [x.strip() for x in pr] + yield pr + # TODO use iparse helper? + + @property + def as_dicts(self) -> 'AsDictHelper': + bl = list(self.blocks) + if len(bl) != 2: + raise RuntimeError('Need two-block table to non-ambiguously guess column names') + hrows = bl[0] + if len(hrows) != 1: + raise RuntimeError(f'Need single row heading to guess column names, got: {hrows}') + columns = hrows[0] + assert len(set(columns)) == len(columns), f'Duplicate column names: {columns}' + return AsDictHelper( + columns=columns, + rows=bl[1], + ) + + +class AsDictHelper: + def __init__(self, columns: Sequence[str], rows: Sequence[Row]) -> None: + self.columns = columns + self._rows = rows + + def __iter__(self) -> Iterator[Dict[str, str]]: + for x in self._rows: + yield {k: v for k, v in zip(self.columns, x)} + + +class Gap: + # todo later, add indices etc + pass + + +Rich = Union[Table, Gap] +def to_rich_text(text: str) -> Iterator[Rich]: + ''' + Convert an org-mode text into a 'rich' text, e.g. tables/lists/etc, interleaved by gaps. + NOTE: you shouldn't rely on the number of items returned by this function, + it might change in the future when more types are supported. + + At the moment only tables are supported. + ''' + lines = text.splitlines(keepends=True) + group: List[str] = [] + last: Type[Rich] = Gap + def emit() -> Rich: + nonlocal group, last + if last is Gap: + res = Gap() + elif last is Table: + res = Table(group) # type: ignore + else: + raise RuntimeError(f'Unexpected type {last}') + group = [] + return res + + for line in lines: + if RE_TABLE_ROW.match(line) or RE_TABLE_SEPARATOR.match(line): + cur = Table + else: + cur = Gap # type: ignore + if cur is not last: + if len(group) > 0: + yield emit() + last = cur + group.append(line) + if len(group) > 0: + yield emit() diff --git a/.venv/lib/python3.12/site-packages/orgparse/inline.py b/.venv/lib/python3.12/site-packages/orgparse/inline.py new file mode 100644 index 00000000..043c99d5 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/inline.py @@ -0,0 +1,48 @@ +""" +Org-mode inline markup parser. +""" + +import re + + +def to_plain_text(org_text): + """ + Convert an org-mode text into a plain text. + + >>> to_plain_text('there is a [[link]] in text') + 'there is a link in text' + >>> to_plain_text('some [[link][more complex link]] here') + 'some more complex link here' + >>> print(to_plain_text('''It can handle + ... [[link][multi + ... line + ... link]]. + ... See also: [[info:org#Link%20format][info:org#Link format]]''')) + It can handle + multi + line + link. + See also: info:org#Link format + + """ + return RE_LINK.sub( + lambda m: m.group('desc0') or m.group('desc1'), + org_text) + + +RE_LINK = re.compile( + r""" + (?: + \[ \[ + (?P<desc0> [^\]]+) + \] \] + ) | + (?: + \[ \[ + (?P<link1> [^\]]+) + \] \[ + (?P<desc1> [^\]]+) + \] \] + ) + """, + re.VERBOSE) diff --git a/.venv/lib/python3.12/site-packages/orgparse/node.py b/.venv/lib/python3.12/site-packages/orgparse/node.py new file mode 100644 index 00000000..7ed1cdba --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/node.py @@ -0,0 +1,1459 @@ +import re +import itertools +from typing import List, Iterable, Iterator, Optional, Union, Tuple, cast, Dict, Set, Sequence, Any + +from .date import OrgDate, OrgDateClock, OrgDateRepeatedTask, parse_sdc, OrgDateScheduled, OrgDateDeadline, OrgDateClosed +from .inline import to_plain_text +from .extra import to_rich_text, Rich + + +def lines_to_chunks(lines: Iterable[str]) -> Iterable[List[str]]: + chunk: List[str] = [] + for l in lines: + if RE_NODE_HEADER.search(l): + yield chunk + chunk = [] + chunk.append(l) + yield chunk + +RE_NODE_HEADER = re.compile(r"^\*+ ") + + +def parse_heading_level(heading): + """ + Get star-stripped heading and its level + + >>> parse_heading_level('* Heading') + ('Heading', 1) + >>> parse_heading_level('******** Heading') + ('Heading', 8) + >>> parse_heading_level('*') # None since no space after star + >>> parse_heading_level('*bold*') # None + >>> parse_heading_level('not heading') # None + + """ + match = RE_HEADING_STARS.search(heading) + if match: + return (match.group(2), len(match.group(1))) + +RE_HEADING_STARS = re.compile(r'^(\*+)\s+(.*?)\s*$') + + +def parse_heading_tags(heading: str) -> Tuple[str, List[str]]: + """ + Get first tags and heading without tags + + >>> parse_heading_tags('HEADING') + ('HEADING', []) + >>> parse_heading_tags('HEADING :TAG1:TAG2:') + ('HEADING', ['TAG1', 'TAG2']) + >>> parse_heading_tags('HEADING: this is still heading :TAG1:TAG2:') + ('HEADING: this is still heading', ['TAG1', 'TAG2']) + >>> parse_heading_tags('HEADING :@tag:_tag_:') + ('HEADING', ['@tag', '_tag_']) + + Here is the spec of tags from Org Mode manual: + + Tags are normal words containing letters, numbers, ``_``, and + ``@``. Tags must be preceded and followed by a single colon, + e.g., ``:work:``. + + -- (info "(org) Tags") + + """ + match = RE_HEADING_TAGS.search(heading) + if match: + heading = match.group(1) + tagstr = match.group(2) + tags = tagstr.split(':') + else: + tags = [] + return (heading, tags) + +# Tags are normal words containing letters, numbers, '_', and '@'. https://orgmode.org/manual/Tags.html +RE_HEADING_TAGS = re.compile(r'(.*?)\s*:([\w@:]+):\s*$') + + +def parse_heading_todos(heading: str, todo_candidates: List[str]) -> Tuple[str, Optional[str]]: + """ + Get TODO keyword and heading without TODO keyword. + + >>> todos = ['TODO', 'DONE'] + >>> parse_heading_todos('Normal heading', todos) + ('Normal heading', None) + >>> parse_heading_todos('TODO Heading', todos) + ('Heading', 'TODO') + + """ + for todo in todo_candidates: + if heading == todo: + return ('', todo) + if heading.startswith(todo + ' '): + return (heading[len(todo) + 1:], todo) + return (heading, None) + + +def parse_heading_priority(heading): + """ + Get priority and heading without priority field. + + >>> parse_heading_priority('HEADING') + ('HEADING', None) + >>> parse_heading_priority('[#A] HEADING') + ('HEADING', 'A') + >>> parse_heading_priority('[#0] HEADING') + ('HEADING', '0') + >>> parse_heading_priority('[#A]') + ('', 'A') + + """ + match = RE_HEADING_PRIORITY.search(heading) + if match: + return (match.group(2), match.group(1)) + else: + return (heading, None) + +RE_HEADING_PRIORITY = re.compile(r'^\s*\[#([A-Z0-9])\] ?(.*)$') + +PropertyValue = Union[str, int, float] +def parse_property(line: str) -> Tuple[Optional[str], Optional[PropertyValue]]: + """ + Get property from given string. + + >>> parse_property(':Some_property: some value') + ('Some_property', 'some value') + >>> parse_property(':Effort: 1:10') + ('Effort', 70) + + """ + prop_key = None + prop_val: Optional[Union[str, int, float]] = None + match = RE_PROP.search(line) + if match: + prop_key = match.group(1) + prop_val = match.group(2) + if prop_key == 'Effort': + prop_val = parse_duration_to_minutes(prop_val) + return (prop_key, prop_val) + +RE_PROP = re.compile(r'^\s*:(.*?):\s*(.*?)\s*$') + +def parse_duration_to_minutes(duration: str) -> Union[float, int]: + """ + Parse duration minutes from given string. + Convert to integer if number has no decimal points + + >>> parse_duration_to_minutes('3:12') + 192 + >>> parse_duration_to_minutes('1:23:45') + 83.75 + >>> parse_duration_to_minutes('1y 3d 3h 4min') + 530464 + >>> parse_duration_to_minutes('1d3h5min') + 1625 + >>> parse_duration_to_minutes('3d 13:35') + 5135 + >>> parse_duration_to_minutes('2.35h') + 141 + >>> parse_duration_to_minutes('10') + 10 + >>> parse_duration_to_minutes('10.') + 10 + >>> parse_duration_to_minutes('1 h') + 60 + >>> parse_duration_to_minutes('') + 0 + """ + + minutes = parse_duration_to_minutes_float(duration) + return int(minutes) if minutes.is_integer() else minutes + +def parse_duration_to_minutes_float(duration: str) -> float: + """ + Parse duration minutes from given string. + The following code is fully compatible with the 'org-duration-to-minutes' function in org mode: + https://github.com/emacs-mirror/emacs/blob/master/lisp/org/org-duration.el + + >>> parse_duration_to_minutes_float('3:12') + 192.0 + >>> parse_duration_to_minutes_float('1:23:45') + 83.75 + >>> parse_duration_to_minutes_float('1y 3d 3h 4min') + 530464.0 + >>> parse_duration_to_minutes_float('1d3h5min') + 1625.0 + >>> parse_duration_to_minutes_float('3d 13:35') + 5135.0 + >>> parse_duration_to_minutes_float('2.35h') + 141.0 + >>> parse_duration_to_minutes_float('10') + 10.0 + >>> parse_duration_to_minutes_float('10.') + 10.0 + >>> parse_duration_to_minutes_float('1 h') + 60.0 + >>> parse_duration_to_minutes_float('') + 0.0 + """ + + match: Optional[Any] + if duration == "": + return 0.0 + if isinstance(duration, float): + return float(duration) + if RE_ORG_DURATION_H_MM.fullmatch(duration): + hours, minutes, *seconds_ = map(float, duration.split(":")) + seconds = seconds_[0] if seconds_ else 0 + return seconds / 60.0 + minutes + 60 * hours + if RE_ORG_DURATION_FULL.fullmatch(duration): + minutes = 0 + for match in RE_ORG_DURATION_UNIT.finditer(duration): + value = float(match.group(1)) + unit = match.group(2) + minutes += value * ORG_DURATION_UNITS[unit] + return float(minutes) + match = RE_ORG_DURATION_MIXED.fullmatch(duration) + if match: + units_part = match.groupdict()['A'] + hms_part = match.groupdict()['B'] + return parse_duration_to_minutes_float(units_part) + parse_duration_to_minutes_float(hms_part) + if RE_FLOAT.fullmatch(duration): + return float(duration) + raise ValueError("Invalid duration format %s" % duration) + +# Conversion factor to minutes for a duration. +ORG_DURATION_UNITS = { + "min": 1, + "h": 60, + "d": 60 * 24, + "w": 60 * 24 * 7, + "m": 60 * 24 * 30, + "y": 60 * 24 * 365.25, +} +# Regexp matching for all units. +ORG_DURATION_UNITS_RE = r'(%s)' % r'|'.join(ORG_DURATION_UNITS.keys()) +# Regexp matching a duration expressed with H:MM or H:MM:SS format. +# Hours can use any number of digits. +ORG_DURATION_H_MM_RE = r'[ \t]*[0-9]+(?::[0-9]{2}){1,2}[ \t]*' +RE_ORG_DURATION_H_MM = re.compile(ORG_DURATION_H_MM_RE) +# Regexp matching a duration with an unit. +# Allowed units are defined in ORG_DURATION_UNITS. +# Match group 1 contains the bare number. +# Match group 2 contains the unit. +ORG_DURATION_UNIT_RE = r'([0-9]+(?:[.][0-9]*)?)[ \t]*' + ORG_DURATION_UNITS_RE +RE_ORG_DURATION_UNIT = re.compile(ORG_DURATION_UNIT_RE) +# Regexp matching a duration expressed with units. +# Allowed units are defined in ORG_DURATION_UNITS. +ORG_DURATION_FULL_RE = r'(?:[ \t]*%s)+[ \t]*' % ORG_DURATION_UNIT_RE +RE_ORG_DURATION_FULL = re.compile(ORG_DURATION_FULL_RE) +# Regexp matching a duration expressed with units and H:MM or H:MM:SS format. +# Allowed units are defined in ORG_DURATION_UNITS. +# Match group A contains units part. +# Match group B contains H:MM or H:MM:SS part. +ORG_DURATION_MIXED_RE = r'(?P<A>([ \t]*%s)+)[ \t]*(?P<B>[0-9]+(?::[0-9][0-9]){1,2})[ \t]*' % ORG_DURATION_UNIT_RE +RE_ORG_DURATION_MIXED = re.compile(ORG_DURATION_MIXED_RE) +# Regexp matching float numbers. +RE_FLOAT = re.compile(r'[0-9]+([.][0-9]*)?') + +def parse_comment(line: str): # -> Optional[Tuple[str, Sequence[str]]]: # todo wtf?? it says 'ABCMeta isn't subscriptable??' + """ + Parse special comment such as ``#+SEQ_TODO`` + + >>> parse_comment('#+SEQ_TODO: TODO | DONE') + ('SEQ_TODO', ['TODO | DONE']) + >>> parse_comment('# not a special comment') # None + + >>> parse_comment('#+FILETAGS: :tag1:tag2:') + ('FILETAGS', ['tag1', 'tag2']) + """ + match = re.match(r'\s*#\+', line) + if match: + end = match.end(0) + comment = line[end:].split(':', maxsplit=1) + if len(comment) >= 2: + key = comment[0] + value = comment[1].strip() + if key.upper() == 'FILETAGS': + # just legacy behaviour; it seems like filetags is the only one that separated by ':' + # see https://orgmode.org/org.html#In_002dbuffer-Settings + return (key, [c.strip() for c in value.split(':') if len(c.strip()) > 0]) + else: + return (key, [value]) + return None + + +def parse_seq_todo(line): + """ + Parse value part of SEQ_TODO/TODO/TYP_TODO comment. + + >>> parse_seq_todo('TODO | DONE') + (['TODO'], ['DONE']) + >>> parse_seq_todo(' Fred Sara Lucy Mike | DONE ') + (['Fred', 'Sara', 'Lucy', 'Mike'], ['DONE']) + >>> parse_seq_todo('| CANCELED') + ([], ['CANCELED']) + >>> parse_seq_todo('REPORT(r) BUG(b) KNOWNCAUSE(k) | FIXED(f)') + (['REPORT', 'BUG', 'KNOWNCAUSE'], ['FIXED']) + + See also: + + * (info "(org) Per-file keywords") + * (info "(org) Fast access to TODO states") + + """ + todo_done = line.split('|', 1) + if len(todo_done) == 2: + (todos, dones) = todo_done + else: + (todos, dones) = (line, '') + strip_fast_access_key = lambda x: x.split('(', 1)[0] + return (list(map(strip_fast_access_key, todos.split())), + list(map(strip_fast_access_key, dones.split()))) + + +class OrgEnv(object): + + """ + Information global to the file (e.g, TODO keywords). + """ + + def __init__(self, todos=['TODO'], dones=['DONE'], + filename='<undefined>'): + self._todos = list(todos) + self._dones = list(dones) + self._todo_not_specified_in_comment = True + self._filename = filename + self._nodes = [] + + @property + def nodes(self): + """ + A list of org nodes. + + >>> OrgEnv().nodes # default is empty (of course) + [] + + >>> from orgparse import loads + >>> loads(''' + ... * Heading 1 + ... ** Heading 2 + ... *** Heading 3 + ... ''').env.nodes # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE + [<orgparse.node.OrgRootNode object at 0x...>, + <orgparse.node.OrgNode object at 0x...>, + <orgparse.node.OrgNode object at 0x...>, + <orgparse.node.OrgNode object at 0x...>] + + """ + return self._nodes + + def add_todo_keys(self, todos, dones): + if self._todo_not_specified_in_comment: + self._todos = [] + self._dones = [] + self._todo_not_specified_in_comment = False + self._todos.extend(todos) + self._dones.extend(dones) + + @property + def todo_keys(self): + """ + TODO keywords defined for this document (file). + + >>> env = OrgEnv() + >>> env.todo_keys + ['TODO'] + + """ + return self._todos + + @property + def done_keys(self): + """ + DONE keywords defined for this document (file). + + >>> env = OrgEnv() + >>> env.done_keys + ['DONE'] + + """ + return self._dones + + @property + def all_todo_keys(self): + """ + All TODO keywords (including DONEs). + + >>> env = OrgEnv() + >>> env.all_todo_keys + ['TODO', 'DONE'] + + """ + return self._todos + self._dones + + @property + def filename(self): + """ + Return a path to the source file or similar information. + + If the org objects are not loaded from a file, this value + will be a string of the form ``<SOME_TEXT>``. + + :rtype: str + + """ + return self._filename + + # parser + + def from_chunks(self, chunks): + yield OrgRootNode.from_chunk(self, next(chunks)) + for chunk in chunks: + yield OrgNode.from_chunk(self, chunk) + + +class OrgBaseNode(Sequence): + + """ + Base class for :class:`OrgRootNode` and :class:`OrgNode` + + .. attribute:: env + + An instance of :class:`OrgEnv`. + All nodes in a same file shares same instance. + + :class:`OrgBaseNode` is an iterable object: + + >>> from orgparse import loads + >>> root = loads(''' + ... * Heading 1 + ... ** Heading 2 + ... *** Heading 3 + ... * Heading 4 + ... ''') + >>> for node in root: + ... print(node) + <BLANKLINE> + * Heading 1 + ** Heading 2 + *** Heading 3 + * Heading 4 + + Note that the first blank line is due to the root node, as + iteration contains the object itself. To skip that, use + slice access ``[1:]``: + + >>> for node in root[1:]: + ... print(node) + * Heading 1 + ** Heading 2 + *** Heading 3 + * Heading 4 + + It also supports sequence protocol. + + >>> print(root[1]) + * Heading 1 + >>> root[0] is root # index 0 means itself + True + >>> len(root) # remember, sequence contains itself + 5 + + Note the difference between ``root[1:]`` and ``root[1]``: + + >>> for node in root[1]: + ... print(node) + * Heading 1 + ** Heading 2 + *** Heading 3 + + Nodes remember the line number information (1-indexed): + + >>> print(root.children[1].linenumber) + 5 + """ + + _body_lines: List[str] # set by the child classes + + def __init__(self, env, index=None) -> None: + """ + Create an :class:`OrgBaseNode` object. + + :type env: :class:`OrgEnv` + :arg env: This will be set to the :attr:`env` attribute. + + """ + self.env = env + + self.linenumber = cast(int, None) # set in parse_lines + + # content + self._lines: List[str] = [] + + self._properties: Dict[str, PropertyValue] = {} + self._timestamps: List[OrgDate] = [] + + # FIXME: use `index` argument to set index. (Currently it is + # done externally in `parse_lines`.) + if index is not None: + self._index = index + """ + Index of `self` in `self.env.nodes`. + + It must satisfy an equality:: + + self.env.nodes[self._index] is self + + This value is used for quick access for iterator and + tree-like traversing. + + """ + + def __iter__(self): + yield self + level = self.level + for node in self.env._nodes[self._index + 1:]: + if node.level > level: + yield node + else: + break + + def __len__(self): + return sum(1 for _ in self) + + def __nonzero__(self): + # As self.__len__ returns non-zero value always this is not + # needed. This function is only for performance. + return True + + __bool__ = __nonzero__ # PY3 + + def __getitem__(self, key): + if isinstance(key, slice): + return itertools.islice(self, key.start, key.stop, key.step) + elif isinstance(key, int): + if key < 0: + key += len(self) + for (i, node) in enumerate(self): + if i == key: + return node + raise IndexError("Out of range {0}".format(key)) + else: + raise TypeError("Inappropriate type {0} for {1}" + .format(type(key), type(self))) + + # tree structure + + def _find_same_level(self, iterable): + for node in iterable: + if node.level < self.level: + return + if node.level == self.level: + return node + + @property + def previous_same_level(self): + """ + Return previous node if exists or None otherwise. + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node 1 + ... * Node 2 + ... ** Node 3 + ... ''') + >>> (n1, n2, n3) = list(root[1:]) + >>> n1.previous_same_level is None + True + >>> n2.previous_same_level is n1 + True + >>> n3.previous_same_level is None # n2 is not at the same level + True + + """ + return self._find_same_level(reversed(self.env._nodes[:self._index])) + + @property + def next_same_level(self): + """ + Return next node if exists or None otherwise. + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node 1 + ... * Node 2 + ... ** Node 3 + ... ''') + >>> (n1, n2, n3) = list(root[1:]) + >>> n1.next_same_level is n2 + True + >>> n2.next_same_level is None # n3 is not at the same level + True + >>> n3.next_same_level is None + True + + """ + return self._find_same_level(self.env._nodes[self._index + 1:]) + + # FIXME: cache parent node + def _find_parent(self): + for node in reversed(self.env._nodes[:self._index]): + if node.level < self.level: + return node + + def get_parent(self, max_level=None): + """ + Return a parent node. + + :arg int max_level: + In the normally structured org file, it is a level + of the ancestor node to return. For example, + ``get_parent(max_level=0)`` returns a root node. + + In the general case, it specify a maximum level of the + desired ancestor node. If there is no ancestor node + whose level is equal to ``max_level``, this function + try to find an ancestor node which level is smaller + than ``max_level``. + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node 1 + ... ** Node 2 + ... ** Node 3 + ... ''') + >>> (n1, n2, n3) = list(root[1:]) + >>> n1.get_parent() is root + True + >>> n2.get_parent() is n1 + True + >>> n3.get_parent() is n1 + True + + For simplicity, accessing :attr:`parent` is alias of calling + :meth:`get_parent` without argument. + + >>> n1.get_parent() is n1.parent + True + >>> root.parent is None + True + + This is a little bit pathological situation -- but works. + + >>> root = loads(''' + ... * Node 1 + ... *** Node 2 + ... ** Node 3 + ... ''') + >>> (n1, n2, n3) = list(root[1:]) + >>> n1.get_parent() is root + True + >>> n2.get_parent() is n1 + True + >>> n3.get_parent() is n1 + True + + Now let's play with `max_level`. + + >>> root = loads(''' + ... * Node 1 (level 1) + ... ** Node 2 (level 2) + ... *** Node 3 (level 3) + ... ''') + >>> (n1, n2, n3) = list(root[1:]) + >>> n3.get_parent() is n2 + True + >>> n3.get_parent(max_level=2) is n2 # same as default + True + >>> n3.get_parent(max_level=1) is n1 + True + >>> n3.get_parent(max_level=0) is root + True + + """ + if max_level is None: + max_level = self.level - 1 + parent = self._find_parent() + while parent.level > max_level: + parent = parent.get_parent() + return parent + + @property + def parent(self): + """ + Alias of :meth:`get_parent()` (calling without argument). + """ + return self.get_parent() + + # FIXME: cache children nodes + def _find_children(self): + nodeiter = iter(self.env._nodes[self._index + 1:]) + try: + node = next(nodeiter) + except StopIteration: + return + if node.level <= self.level: + return + yield node + last_child_level = node.level + for node in nodeiter: + if node.level <= self.level: + return + if node.level <= last_child_level: + yield node + last_child_level = node.level + + @property + def children(self): + """ + A list of child nodes. + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node 1 + ... ** Node 2 + ... *** Node 3 + ... ** Node 4 + ... ''') + >>> (n1, n2, n3, n4) = list(root[1:]) + >>> (c1, c2) = n1.children + >>> c1 is n2 + True + >>> c2 is n4 + True + + Note the difference to ``n1[1:]``, which returns the Node 3 also: + + >>> (m1, m2, m3) = list(n1[1:]) + >>> m2 is n3 + True + + """ + return list(self._find_children()) + + @property + def root(self): + """ + The root node. + + >>> from orgparse import loads + >>> root = loads('* Node 1') + >>> n1 = root[1] + >>> n1.root is root + True + + """ + root = self + while True: + parent = root.get_parent() + if not parent: + return root + root = parent + + @property + def properties(self) -> Dict[str, PropertyValue]: + """ + Node properties as a dictionary. + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node + ... :PROPERTIES: + ... :SomeProperty: value + ... :END: + ... ''') + >>> root.children[0].properties['SomeProperty'] + 'value' + + """ + return self._properties + + def get_property(self, key, val=None) -> Optional[PropertyValue]: + """ + Return property named ``key`` if exists or ``val`` otherwise. + + :arg str key: + Key of property. + + :arg val: + Default value to return. + + """ + return self._properties.get(key, val) + + # parser + + @classmethod + def from_chunk(cls, env, lines): + self = cls(env) + self._lines = lines + self._parse_comments() + return self + + def _parse_comments(self): + special_comments: Dict[str, List[str]] = {} + for line in self._lines: + parsed = parse_comment(line) + if parsed: + (key, vals) = parsed + key = key.upper() # case insensitive, so keep as uppercase + special_comments.setdefault(key, []).extend(vals) + self._special_comments = special_comments + # parse TODO keys and store in OrgEnv + for todokey in ['TODO', 'SEQ_TODO', 'TYP_TODO']: + for val in special_comments.get(todokey, []): + self.env.add_todo_keys(*parse_seq_todo(val)) + + def _iparse_properties(self, ilines: Iterator[str]) -> Iterator[str]: + self._properties = {} + in_property_field = False + for line in ilines: + if in_property_field: + if line.find(":END:") >= 0: + break + else: + (key, val) = parse_property(line) + if key is not None and val is not None: + self._properties.update({key: val}) + elif line.find(":PROPERTIES:") >= 0: + in_property_field = True + else: + yield line + for line in ilines: + yield line + + # misc + + @property + def level(self): + """ + Level of this node. + + :rtype: int + + """ + raise NotImplementedError + + def _get_tags(self, inher=False) -> Set[str]: + """ + Return tags + + :arg bool inher: + Mix with tags of all ancestor nodes if ``True``. + + :rtype: set + + """ + return set() + + @property + def tags(self) -> Set[str]: + """ + Tags of this and parent's node. + + >>> from orgparse import loads + >>> n2 = loads(''' + ... * Node 1 :TAG1: + ... ** Node 2 :TAG2: + ... ''')[2] + >>> n2.tags == set(['TAG1', 'TAG2']) + True + + """ + return self._get_tags(inher=True) + + @property + def shallow_tags(self) -> Set[str]: + """ + Tags defined for this node (don't look-up parent nodes). + + >>> from orgparse import loads + >>> n2 = loads(''' + ... * Node 1 :TAG1: + ... ** Node 2 :TAG2: + ... ''')[2] + >>> n2.shallow_tags == set(['TAG2']) + True + + """ + return self._get_tags(inher=False) + + @staticmethod + def _get_text(text, format='plain'): + if format == 'plain': + return to_plain_text(text) + elif format == 'raw': + return text + elif format == 'rich': + return to_rich_text(text) + else: + raise ValueError('format={0} is not supported.'.format(format)) + + def get_body(self, format='plain') -> str: + """ + Return a string of body text. + + See also: :meth:`get_heading`. + + """ + return self._get_text( + '\n'.join(self._body_lines), format) if self._lines else '' + + @property + def body(self) -> str: + """Alias of ``.get_body(format='plain')``.""" + return self.get_body() + + @property + def body_rich(self) -> Iterator[Rich]: + r = self.get_body(format='rich') + return cast(Iterator[Rich], r) # meh.. + + @property + def heading(self) -> str: + raise NotImplementedError + + def is_root(self): + """ + Return ``True`` when it is a root node. + + >>> from orgparse import loads + >>> root = loads('* Node 1') + >>> root.is_root() + True + >>> n1 = root[1] + >>> n1.is_root() + False + + """ + return False + + def get_timestamps(self, active=False, inactive=False, + range=False, point=False): + """ + Return a list of timestamps in the body text. + + :type active: bool + :arg active: Include active type timestamps. + :type inactive: bool + :arg inactive: Include inactive type timestamps. + :type range: bool + :arg range: Include timestamps which has end date. + :type point: bool + :arg point: Include timestamps which has no end date. + + :rtype: list of :class:`orgparse.date.OrgDate` subclasses + + + Consider the following org node: + + >>> from orgparse import loads + >>> node = loads(''' + ... * Node + ... CLOSED: [2012-02-26 Sun 21:15] SCHEDULED: <2012-02-26 Sun> + ... CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 + ... Some inactive timestamp [2012-02-23 Thu] in body text. + ... Some active timestamp <2012-02-24 Fri> in body text. + ... Some inactive time range [2012-02-25 Sat]--[2012-02-27 Mon]. + ... Some active time range <2012-02-26 Sun>--<2012-02-28 Tue>. + ... ''').children[0] + + The default flags are all off, so it does not return anything. + + >>> node.get_timestamps() + [] + + You can fetch appropriate timestamps using keyword arguments. + + >>> node.get_timestamps(inactive=True, point=True) + [OrgDate((2012, 2, 23), None, False)] + >>> node.get_timestamps(active=True, point=True) + [OrgDate((2012, 2, 24))] + >>> node.get_timestamps(inactive=True, range=True) + [OrgDate((2012, 2, 25), (2012, 2, 27), False)] + >>> node.get_timestamps(active=True, range=True) + [OrgDate((2012, 2, 26), (2012, 2, 28))] + + This is more complex example. Only active timestamps, + regardless of range/point type. + + >>> node.get_timestamps(active=True, point=True, range=True) + [OrgDate((2012, 2, 24)), OrgDate((2012, 2, 26), (2012, 2, 28))] + + """ + return [ + ts for ts in self._timestamps if + (((active and ts.is_active()) or + (inactive and not ts.is_active())) and + ((range and ts.has_end()) or + (point and not ts.has_end())))] + + @property + def datelist(self): + """ + Alias of ``.get_timestamps(active=True, inactive=True, point=True)``. + + :rtype: list of :class:`orgparse.date.OrgDate` subclasses + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node with point dates <2012-02-25 Sat> + ... CLOSED: [2012-02-25 Sat 21:15] + ... Some inactive timestamp [2012-02-26 Sun] in body text. + ... Some active timestamp <2012-02-27 Mon> in body text. + ... ''') + >>> root.children[0].datelist # doctest: +NORMALIZE_WHITESPACE + [OrgDate((2012, 2, 25)), + OrgDate((2012, 2, 26), None, False), + OrgDate((2012, 2, 27))] + + """ + return self.get_timestamps(active=True, inactive=True, point=True) + + @property + def rangelist(self): + """ + Alias of ``.get_timestamps(active=True, inactive=True, range=True)``. + + :rtype: list of :class:`orgparse.date.OrgDate` subclasses + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node with range dates <2012-02-25 Sat>--<2012-02-28 Tue> + ... CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 + ... Some inactive time range [2012-02-25 Sat]--[2012-02-27 Mon]. + ... Some active time range <2012-02-26 Sun>--<2012-02-28 Tue>. + ... Some time interval <2012-02-27 Mon 11:23-12:10>. + ... ''') + >>> root.children[0].rangelist # doctest: +NORMALIZE_WHITESPACE + [OrgDate((2012, 2, 25), (2012, 2, 28)), + OrgDate((2012, 2, 25), (2012, 2, 27), False), + OrgDate((2012, 2, 26), (2012, 2, 28)), + OrgDate((2012, 2, 27, 11, 23, 0), (2012, 2, 27, 12, 10, 0))] + + """ + return self.get_timestamps(active=True, inactive=True, range=True) + + def __str__(self) -> str: + return "\n".join(self._lines) + + # todo hmm, not sure if it really belongs here and not to OrgRootNode? + def get_file_property_list(self, property): + """ + Return a list of the selected property + """ + vals = self._special_comments.get(property.upper(), None) + return [] if vals is None else vals + + def get_file_property(self, property): + """ + Return a single element of the selected property or None if it doesn't exist + """ + vals = self._special_comments.get(property.upper(), None) + if vals is None: + return None + elif len(vals) == 1: + return vals[0] + else: + raise RuntimeError('Multiple values for property {}: {}'.format(property, vals)) + + +class OrgRootNode(OrgBaseNode): + + """ + Node to represent a file. Its body contains all lines before the first + headline + + See :class:`OrgBaseNode` for other available functions. + """ + + @property + def heading(self) -> str: + return '' + + def _get_tags(self, inher=False) -> Set[str]: + filetags = self.get_file_property_list('FILETAGS') + return set(filetags) + + @property + def level(self): + return 0 + + def get_parent(self, max_level=None): + return None + + def is_root(self): + return True + + # parsers + + def _parse_pre(self): + """Call parsers which must be called before tree structuring""" + ilines: Iterator[str] = iter(self._lines) + ilines = self._iparse_properties(ilines) + ilines = self._iparse_timestamps(ilines) + self._body_lines = list(ilines) + + def _iparse_timestamps(self, ilines: Iterator[str]) -> Iterator[str]: + self._timestamps = [] + for line in ilines: + self._timestamps.extend(OrgDate.list_from_str(line)) + yield line + + +class OrgNode(OrgBaseNode): + + """ + Node to represent normal org node + + See :class:`OrgBaseNode` for other available functions. + + """ + + def __init__(self, *args, **kwds) -> None: + super(OrgNode, self).__init__(*args, **kwds) + # fixme instead of casts, should organize code in such a way that they aren't necessary + self._heading = cast(str, None) + self._level = None + self._tags = cast(List[str], None) + self._todo: Optional[str] = None + self._priority = None + self._scheduled = OrgDateScheduled(None) + self._deadline = OrgDateDeadline(None) + self._closed = OrgDateClosed(None) + self._clocklist: List[OrgDateClock] = [] + self._body_lines: List[str] = [] + self._repeated_tasks: List[OrgDateRepeatedTask] = [] + + # parser + + def _parse_pre(self): + """Call parsers which must be called before tree structuring""" + self._parse_heading() + # FIXME: make the following parsers "lazy" + ilines: Iterator[str] = iter(self._lines) + try: + next(ilines) # skip heading + except StopIteration: + return + ilines = self._iparse_sdc(ilines) + ilines = self._iparse_clock(ilines) + ilines = self._iparse_properties(ilines) + ilines = self._iparse_repeated_tasks(ilines) + ilines = self._iparse_timestamps(ilines) + self._body_lines = list(ilines) + + def _parse_heading(self) -> None: + heading = self._lines[0] + (heading, self._level) = parse_heading_level(heading) + (heading, self._tags) = parse_heading_tags(heading) + (heading, self._todo) = parse_heading_todos( + heading, self.env.all_todo_keys) + (heading, self._priority) = parse_heading_priority(heading) + self._heading = heading + + # The following ``_iparse_*`` methods are simple generator based + # parser. See ``_parse_pre`` for how it is used. The principle + # is simple: these methods get an iterator and returns an iterator. + # If the item returned by the input iterator must be dedicated to + # the parser, do not yield the item or yield it as-is otherwise. + + def _iparse_sdc(self, ilines: Iterator[str]) -> Iterator[str]: + """ + Parse SCHEDULED, DEADLINE and CLOSED time tamps. + + They are assumed be in the first line. + + """ + try: + line = next(ilines) + except StopIteration: + return + (self._scheduled, self._deadline, self._closed) = parse_sdc(line) + + if not (self._scheduled or + self._deadline or + self._closed): + yield line # when none of them were found + + for line in ilines: + yield line + + def _iparse_clock(self, ilines: Iterator[str]) -> Iterator[str]: + self._clocklist = [] + for line in ilines: + cl = OrgDateClock.from_str(line) + if cl: + self._clocklist.append(cl) + else: + yield line + + def _iparse_timestamps(self, ilines: Iterator[str]) -> Iterator[str]: + self._timestamps = [] + self._timestamps.extend(OrgDate.list_from_str(self._heading)) + for l in ilines: + self._timestamps.extend(OrgDate.list_from_str(l)) + yield l + + def _iparse_repeated_tasks(self, ilines: Iterator[str]) -> Iterator[str]: + self._repeated_tasks = [] + for line in ilines: + match = self._repeated_tasks_re.search(line) + if match: + # FIXME: move this parsing to OrgDateRepeatedTask.from_str + mdict = match.groupdict() + done_state = mdict['done'] + todo_state = mdict['todo'] + date = OrgDate.from_str(mdict['date']) + self._repeated_tasks.append( + OrgDateRepeatedTask(date.start, todo_state, done_state)) + else: + yield line + + _repeated_tasks_re = re.compile( + r''' + \s*- \s+ + State \s+ "(?P<done> [^"]+)" \s+ + from \s+ "(?P<todo> [^"]+)" \s+ + \[ (?P<date> [^\]]+) \]''', + re.VERBOSE) + + def get_heading(self, format='plain'): + """ + Return a string of head text without tags and TODO keywords. + + >>> from orgparse import loads + >>> node = loads('* TODO Node 1').children[0] + >>> node.get_heading() + 'Node 1' + + It strips off inline markup by default (``format='plain'``). + You can get the original raw string by specifying + ``format='raw'``. + + >>> node = loads('* [[link][Node 1]]').children[0] + >>> node.get_heading() + 'Node 1' + >>> node.get_heading(format='raw') + '[[link][Node 1]]' + + """ + return self._get_text(self._heading, format) + + @property + def heading(self) -> str: + """Alias of ``.get_heading(format='plain')``.""" + return self.get_heading() + + @property + def level(self): + return self._level + """ + Level attribute of this node. Top level node is level 1. + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node 1 + ... ** Node 2 + ... ''') + >>> (n1, n2) = root.children + >>> root.level + 0 + >>> n1.level + 1 + >>> n2.level + 2 + + """ + + @property + def priority(self): + """ + Priority attribute of this node. It is None if undefined. + + >>> from orgparse import loads + >>> (n1, n2) = loads(''' + ... * [#A] Node 1 + ... * Node 2 + ... ''').children + >>> n1.priority + 'A' + >>> n2.priority is None + True + + """ + return self._priority + + def _get_tags(self, inher=False) -> Set[str]: + tags = set(self._tags) + if inher: + parent = self.get_parent() + if parent: + return tags | parent._get_tags(inher=True) + return tags + + @property + def todo(self) -> Optional[str]: + """ + A TODO keyword of this node if exists or None otherwise. + + >>> from orgparse import loads + >>> root = loads('* TODO Node 1') + >>> root.children[0].todo + 'TODO' + + """ + return self._todo + + @property + def scheduled(self): + """ + Return scheduled timestamp + + :rtype: a subclass of :class:`orgparse.date.OrgDate` + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node + ... SCHEDULED: <2012-02-26 Sun> + ... ''') + >>> root.children[0].scheduled + OrgDateScheduled((2012, 2, 26)) + + """ + return self._scheduled + + @property + def deadline(self): + """ + Return deadline timestamp. + + :rtype: a subclass of :class:`orgparse.date.OrgDate` + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node + ... DEADLINE: <2012-02-26 Sun> + ... ''') + >>> root.children[0].deadline + OrgDateDeadline((2012, 2, 26)) + + """ + return self._deadline + + @property + def closed(self): + """ + Return timestamp of closed time. + + :rtype: a subclass of :class:`orgparse.date.OrgDate` + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node + ... CLOSED: [2012-02-26 Sun 21:15] + ... ''') + >>> root.children[0].closed + OrgDateClosed((2012, 2, 26, 21, 15, 0)) + + """ + return self._closed + + @property + def clock(self): + """ + Return a list of clocked timestamps + + :rtype: a list of a subclass of :class:`orgparse.date.OrgDate` + + >>> from orgparse import loads + >>> root = loads(''' + ... * Node + ... CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 + ... ''') + >>> root.children[0].clock + [OrgDateClock((2012, 2, 26, 21, 10, 0), (2012, 2, 26, 21, 15, 0))] + + """ + return self._clocklist + + def has_date(self): + """ + Return ``True`` if it has any kind of timestamp + """ + return (self.scheduled or + self.deadline or + self.datelist or + self.rangelist) + + @property + def repeated_tasks(self): + """ + Get repeated tasks marked DONE in an entry having repeater. + + :rtype: list of :class:`orgparse.date.OrgDateRepeatedTask` + + >>> from orgparse import loads + >>> node = loads(''' + ... * TODO Pay the rent + ... DEADLINE: <2005-10-01 Sat +1m> + ... - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + ... - State "DONE" from "TODO" [2005-08-01 Mon 19:44] + ... - State "DONE" from "TODO" [2005-07-01 Fri 17:27] + ... ''').children[0] + >>> node.repeated_tasks # doctest: +NORMALIZE_WHITESPACE + [OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE'), + OrgDateRepeatedTask((2005, 8, 1, 19, 44, 0), 'TODO', 'DONE'), + OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), 'TODO', 'DONE')] + >>> node.repeated_tasks[0].before + 'TODO' + >>> node.repeated_tasks[0].after + 'DONE' + + Repeated tasks in ``:LOGBOOK:`` can be fetched by the same code. + + >>> node = loads(''' + ... * TODO Pay the rent + ... DEADLINE: <2005-10-01 Sat +1m> + ... :LOGBOOK: + ... - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + ... - State "DONE" from "TODO" [2005-08-01 Mon 19:44] + ... - State "DONE" from "TODO" [2005-07-01 Fri 17:27] + ... :END: + ... ''').children[0] + >>> node.repeated_tasks # doctest: +NORMALIZE_WHITESPACE + [OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE'), + OrgDateRepeatedTask((2005, 8, 1, 19, 44, 0), 'TODO', 'DONE'), + OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), 'TODO', 'DONE')] + + See: `(info "(org) Repeated tasks") + <http://orgmode.org/manual/Repeated-tasks.html>`_ + + """ + return self._repeated_tasks + + +def parse_lines(lines: Iterable[str], filename, env=None) -> OrgNode: + if not env: + env = OrgEnv(filename=filename) + elif env.filename != filename: + raise ValueError('If env is specified, filename must match') + + # parse into node of list (environment will be parsed) + ch1, ch2 = itertools.tee(lines_to_chunks(lines)) + linenos = itertools.accumulate(itertools.chain([0], (len(c) for c in ch1))) + nodes = env.from_chunks(ch2) + nodelist = [] + for lineno, node in zip(linenos, nodes): + lineno += 1 # in text editors lines are 1-indexed + node.linenumber = lineno + nodelist.append(node) + # parse headings (level, TODO, TAGs, and heading) + nodelist[0]._index = 0 + # parse the root node + nodelist[0]._parse_pre() + for (i, node) in enumerate(nodelist[1:], 1): # nodes except root node + node._index = i + node._parse_pre() + env._nodes = nodelist + return nodelist[0] # root diff --git a/.venv/lib/python3.12/site-packages/orgparse/py.typed b/.venv/lib/python3.12/site-packages/orgparse/py.typed new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/py.typed diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/__init__.py b/.venv/lib/python3.12/site-packages/orgparse/tests/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/__init__.py diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.org new file mode 100644 index 00000000..3a373334 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.org @@ -0,0 +1,17 @@ +#+STARTUP: hidestars +#+SEQ_TODO: TODO1 TODO2 TODO3 TODO4 + +* TODO1 Heading 0 :TAG1: +** TODO2 Heading 1 :TAG2: +*** TODO3 Heading 2 :TAG3: +**** TODO4 Heading 3 :TAG4: + CLOSED: [2010-08-06 Fri 21:45] +** Heading 4 +** Heading 5 +* Heading 6 :TAG2: +** Heading 7 :TAG2: +*** Heading 8 +***** Heading 9 :TAG3:TAG4: +**** Heading 10 :TAG1: +** Heading 11 +* Heading 12 diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.py new file mode 100644 index 00000000..c0b23d1d --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.py @@ -0,0 +1,33 @@ +from typing import Any, Dict, Set + + +def nodedict(i, level, todo=None, shallow_tags=set([]), tags=set([])) -> Dict[str, Any]: + return dict( + heading="Heading {0}".format(i), + level=level, + todo=todo, + shallow_tags=shallow_tags, + tags=tags, + ) + + +def tags(nums) -> Set[str]: + return set(map('TAG{0}'.format, nums)) + + +data = [ + nodedict(i, *vals) for (i, vals) in enumerate([ # type: ignore[misc] + [1, 'TODO1', tags([1]) , tags(range(1, 2))], + [2, 'TODO2', tags([2]) , tags(range(1, 3))], + [3, 'TODO3', tags([3]) , tags(range(1, 4))], + [4, 'TODO4', tags([4]) , tags(range(1, 5))], + [2, None , tags([]) , tags([1]) ], + [2, None , tags([]) , tags([1]) ], + [1, None , tags([2]) , tags([2]) ], + [2, None , tags([2]) , tags([2]) ], + [3, None , tags([]) , tags([2]) ], + [5, None , tags([3, 4]), tags([2, 3, 4]) ], + [4, None , tags([1]) , tags([1, 2]) ], + [2, None , tags([]) , tags([2]) ], + [1], + ])] diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.org new file mode 100644 index 00000000..99e202b5 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.org @@ -0,0 +1,29 @@ +#+STARTUP: hidestars +* DONE [#A] A node with a lot of attributes + SCHEDULED: <2010-08-06 Fri> DEADLINE: <2010-08-10 Tue> CLOSED: [2010-08-08 Sun 18:00] + CLOCK: [2010-08-08 Sun 17:40]--[2010-08-08 Sun 17:50] => 0:10 + CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] => 0:30 + :PROPERTIES: + :Effort: 1:10 + :END: + - <2010-08-16 Mon> DateList + - <2010-08-07 Sat>--<2010-08-08 Sun> + - <2010-08-09 Mon 00:30>--<2010-08-10 Tue 13:20> RangeList + - <2019-08-10 Sat 16:30-17:30> TimeRange +* A node without any attributed +* DONE [#A] A node with a lot of attributes + SCHEDULED: <2010-08-06 Fri> DEADLINE: <2010-08-10 Tue> CLOSED: [2010-08-08 Sun 18:00] + CLOCK: [2010-08-08 Sun 17:40]--[2010-08-08 Sun 17:50] => 0:10 + CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] => 0:30 + :PROPERTIES: + :Effort: 1:10 + :END: + - <2010-08-16 Mon> DateList + - <2010-08-07 Sat>--<2010-08-08 Sun> + - <2010-08-09 Mon 00:30>--<2010-08-10 Tue 13:20> RangeList + - <2019-08-10 Sat 16:30-17:30> TimeRange +* range in deadline +DEADLINE: <2019-09-06 Fri 10:00--11:20> + body +* node with a second line but no date +body diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.py new file mode 100644 index 00000000..d4555dea --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.py @@ -0,0 +1,73 @@ +from typing import Dict, Any + +from orgparse.date import ( + OrgDate, OrgDateScheduled, OrgDateDeadline, OrgDateClosed, + OrgDateClock, +) + +Raw = Dict[str, Any] + +node1: Raw = dict( + heading="A node with a lot of attributes", + priority='A', + scheduled=OrgDateScheduled((2010, 8, 6)), + deadline=OrgDateDeadline((2010, 8, 10)), + closed=OrgDateClosed((2010, 8, 8, 18, 0)), + clock=[ + OrgDateClock((2010, 8, 8, 17, 40), (2010, 8, 8, 17, 50), 10), + OrgDateClock((2010, 8, 8, 17, 00), (2010, 8, 8, 17, 30), 30), + ], + properties=dict(Effort=70), + datelist=[OrgDate((2010, 8, 16))], + rangelist=[ + OrgDate((2010, 8, 7), (2010, 8, 8)), + OrgDate((2010, 8, 9, 0, 30), (2010, 8, 10, 13, 20)), + OrgDate((2019, 8, 10, 16, 30, 0), (2019, 8, 10, 17, 30, 0)), + ], + body="""\ + - <2010-08-16 Mon> DateList + - <2010-08-07 Sat>--<2010-08-08 Sun> + - <2010-08-09 Mon 00:30>--<2010-08-10 Tue 13:20> RangeList + - <2019-08-10 Sat 16:30-17:30> TimeRange""" +) + +node2: Raw = dict( + heading="A node without any attributed", + priority=None, + scheduled=OrgDateScheduled(None), + deadline=OrgDateDeadline(None), + closed=OrgDateClosed(None), + clock=[], + properties={}, + datelist=[], + rangelist=[], + body="", +) + +node3: Raw = dict( + heading="range in deadline", + priority=None, + scheduled=OrgDateScheduled(None), + deadline=OrgDateDeadline((2019, 9, 6, 10, 0), (2019, 9, 6, 11, 20)), + closed=OrgDateClosed(None), + clock=[], + properties={}, + datelist=[], + rangelist=[], + body=" body", +) + +node4: Raw = dict( + heading="node with a second line but no date", + priority=None, + scheduled=OrgDateScheduled(None), + deadline=OrgDateDeadline(None), + closed=OrgDateClosed(None), + clock=[], + properties={}, + datelist=[], + rangelist=[], + body="body", +) + +data = [node1, node2, node1, node3, node4] diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.org new file mode 100644 index 00000000..5f4b6fb2 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.org @@ -0,0 +1,31 @@ +* G0-H1 + +* G1-H1 +** G1-H2 +*** G1-H3 + +* G2-H1 +*** G2-H2 +** G2-H3 + +* G3-H1 +**** G3-H2 +** G3-H3 + +* G4-H1 +**** G4-H2 +*** G4-H3 +** G4-H4 + +* G5-H1 +** G5-H2 +*** G5-H3 +** G5-H4 + +* G6-H1 +** G6-H2 +**** G6-H3 +*** G6-H4 +** G6-H5 + +* G7-H1 diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.py new file mode 100644 index 00000000..80a8e779 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.py @@ -0,0 +1,44 @@ +from typing import Any, Dict + + +def nodedict(parent, children=[], previous=None, next=None) -> Dict[str, Any]: + return dict(parent_heading=parent, + children_heading=children, + previous_same_level_heading=previous, + next_same_level_heading=next) + + +data = [nodedict(*args) for args in [ + # G0 + (None, [], None, 'G1-H1'), + # G1 + (None, ['G1-H2'], 'G0-H1', 'G2-H1'), + ('G1-H1', ['G1-H3']), + ('G1-H2',), + # G2 + (None, ['G2-H2', 'G2-H3'], 'G1-H1', 'G3-H1'), + ('G2-H1',), + ('G2-H1',), + # G3 + (None, ['G3-H2', 'G3-H3'], 'G2-H1', 'G4-H1'), + ('G3-H1',), + ('G3-H1',), + # G4 + (None, ['G4-H2', 'G4-H3', 'G4-H4'], 'G3-H1', 'G5-H1'), + ('G4-H1',), + ('G4-H1',), + ('G4-H1',), + # G5 + (None, ['G5-H2', 'G5-H4'], 'G4-H1', 'G6-H1'), + ('G5-H1', ['G5-H3'], None, 'G5-H4'), + ('G5-H2',), + ('G5-H1', [], 'G5-H2'), + # G6 + (None, ['G6-H2', 'G6-H5'], 'G5-H1', 'G7-H1'), + ('G6-H1', ['G6-H3', 'G6-H4'], None, 'G6-H5'), + ('G6-H2',), + ('G6-H2',), + ('G6-H1', [], 'G6-H2'), + # G7 + (None, [], 'G6-H1'), +]] diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.org new file mode 100644 index 00000000..9e9c79b5 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.org @@ -0,0 +1,5 @@ +* TODO Pay the rent + DEADLINE: <2005-10-01 Sat +1m> + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + - State "DONE" from "TODO" [2005-08-01 Mon 19:44] + - State "DONE" from "TODO" [2005-07-01 Fri 17:27] diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.py new file mode 100644 index 00000000..18cfe121 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.py @@ -0,0 +1,13 @@ +from orgparse.date import OrgDateRepeatedTask, OrgDateDeadline + + +data = [dict( + heading='Pay the rent', + todo='TODO', + deadline=OrgDateDeadline((2005, 10, 1)), + repeated_tasks=[ + OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE'), + OrgDateRepeatedTask((2005, 8, 1, 19, 44, 0), 'TODO', 'DONE'), + OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), 'TODO', 'DONE'), + ] +)] diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.org new file mode 100644 index 00000000..e89ec262 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.org @@ -0,0 +1,7 @@ +* LOGBOOK drawer test + :LOGBOOK: + CLOCK: [2012-10-26 Fri 16:01] + CLOCK: [2012-10-26 Fri 14:50]--[2012-10-26 Fri 15:00] => 0:10 + CLOCK: [2012-10-26 Fri 14:30]--[2012-10-26 Fri 14:40] => 0:10 + CLOCK: [2012-10-26 Fri 14:10]--[2012-10-26 Fri 14:20] => 0:10 + :END: diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.py new file mode 100644 index 00000000..457c5fa1 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.py @@ -0,0 +1,11 @@ +from orgparse.date import OrgDateClock + +data = [dict( + heading='LOGBOOK drawer test', + clock=[ + OrgDateClock((2012, 10, 26, 16, 1)), + OrgDateClock((2012, 10, 26, 14, 50), (2012, 10, 26, 15, 00)), + OrgDateClock((2012, 10, 26, 14, 30), (2012, 10, 26, 14, 40)), + OrgDateClock((2012, 10, 26, 14, 10), (2012, 10, 26, 14, 20)), + ] +)] diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.org new file mode 100644 index 00000000..651d7e09 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.org @@ -0,0 +1,9 @@ +* Node 0 :tag: +* Node 1 :@tag: +* Node 2 :tag1:tag2: +* Node 3 :_: +* Node 4 :@: +* Node 5 :@_: +* Node 6 :_tag_: +* Heading: :with:colon: :tag: +* unicode :ёж:tag:háček: diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.py new file mode 100644 index 00000000..52aee638 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +def nodedict(i, tags): + return dict( + heading="Node {0}".format(i), + tags=set(tags), + ) + + +data = [ + nodedict(i, *vals) for (i, vals) in enumerate([ + [["tag"]], + [["@tag"]], + [["tag1", "tag2"]], + [["_"]], + [["@"]], + [["@_"]], + [["_tag_"]], + ])] + [ + dict(heading='Heading: :with:colon:', tags=set(["tag"])), + ] + [ + dict(heading='unicode', tags=set(['ёж', 'tag', 'háček'])), + ] diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/__init__.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/__init__.py diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/test_data.py b/.venv/lib/python3.12/site-packages/orgparse/tests/test_data.py new file mode 100644 index 00000000..f315878e --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/test_data.py @@ -0,0 +1,154 @@ +from glob import glob +import os +from pathlib import Path +import pickle + +from .. import load, loads + +import pytest + +DATADIR = os.path.join(os.path.dirname(__file__), 'data') + + +def load_data(path): + """Load data from python file""" + ns = {} # type: ignore + # read_bytes() and compile hackery to avoid encoding issues (e.g. see 05_tags) + exec(compile(Path(path).read_bytes(), path, 'exec'), ns) + return ns['data'] + + +def value_from_data_key(node, key): + """ + Helper function for check_data. Get value from Orgnode by key. + """ + if key == 'tags_inher': + return node.tags + elif key == 'children_heading': + return [c.heading for c in node.children] + elif key in ('parent_heading', + 'previous_same_level_heading', + 'next_same_level_heading', + ): + othernode = getattr(node, key.rsplit('_', 1)[0]) + if othernode and not othernode.is_root(): + return othernode.heading + else: + return + else: + return getattr(node, key) + + +def data_path(dataname, ext): + return os.path.join(DATADIR, '{0}.{1}'.format(dataname, ext)) + + +def get_datanames(): + for oname in sorted(glob(os.path.join(DATADIR, '*.org'))): + yield os.path.splitext(os.path.basename(oname))[0] + + +@pytest.mark.parametrize('dataname', get_datanames()) +def test_data(dataname): + """ + Compare parsed data from 'data/*.org' and its correct answer 'data/*.py' + """ + oname = data_path(dataname, "org") + data = load_data(data_path(dataname, "py")) + root = load(oname) + + for (i, (node, kwds)) in enumerate(zip(root[1:], data)): + for key in kwds: + val = value_from_data_key(node, key) + assert kwds[key] == val, 'check value of {0}-th node of key "{1}" from "{2}".\n\nParsed:\n{3}\n\nReal:\n{4}'.format(i, key, dataname, val, kwds[key]) + assert type(kwds[key]) == type(val), 'check type of {0}-th node of key "{1}" from "{2}".\n\nParsed:\n{3}\n\nReal:\n{4}'.format(i, key, dataname, type(val), type(kwds[key])) + + assert root.env.filename == oname + + +@pytest.mark.parametrize('dataname', get_datanames()) +def test_picklable(dataname): + oname = data_path(dataname, "org") + root = load(oname) + pickle.dumps(root) + + + +def test_iter_node(): + root = loads(""" +* H1 +** H2 +*** H3 +* H4 +** H5 +""") + node = root[1] + assert node.heading == 'H1' + + by_iter = [n.heading for n in node] + assert by_iter == ['H1', 'H2', 'H3'] + + +def test_commented_headings_do_not_appear_as_children(): + root = loads("""\ +* H1 +#** H2 +** H3 +#* H4 +#** H5 +* H6 +""") + assert root.linenumber == 1 + top_level = root.children + assert len(top_level) == 2 + + h1 = top_level[0] + assert h1.heading == "H1" + assert h1.get_body() == "#** H2" + assert h1.linenumber == 1 + + [h3] = h1.children + assert h3.heading == "H3" + assert h3.get_body() == "#* H4\n#** H5" + assert h3.linenumber == 3 + + h6 = top_level[1] + assert h6.heading == "H6" + assert len(h6.children) == 0 + assert h6.linenumber == 6 + + +def test_commented_clock_entries_are_ignored_by_node_clock(): + root = loads("""\ +* Heading +# * Floss +# SCHEDULED: <2019-06-22 Sat 08:30 .+1w> +# :LOGBOOK: +# CLOCK: [2019-06-04 Tue 16:00]--[2019-06-04 Tue 17:00] => 1:00 +# :END: +""") + [node] = root.children[0] + assert node.heading == "Heading" + assert node.clock == [] + + +def test_commented_scheduled_marker_is_ignored_by_node_scheduled(): + root = loads("""\ +* Heading +# SCHEDULED: <2019-06-22 Sat 08:30 .+1w> +""") + [node] = root.children[0] + assert node.heading == "Heading" + assert node.scheduled.start is None + + +def test_commented_property_is_ignored_by_node_get_property(): + root = loads("""\ +* Heading +# :PROPERTIES: +# :PROPER-TEA: backup +# :END: +""") + [node] = root.children[0] + assert node.heading == "Heading" + assert node.get_property("PROPER-TEA") is None diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/test_date.py b/.venv/lib/python3.12/site-packages/orgparse/tests/test_date.py new file mode 100644 index 00000000..0f39575b --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/test_date.py @@ -0,0 +1,42 @@ +from orgparse.date import OrgDate, OrgDateScheduled, OrgDateDeadline, OrgDateClock, OrgDateClosed +import datetime + + +def test_date_as_string() -> None: + + testdate = datetime.date(2021, 9, 3) + testdate2 = datetime.date(2021, 9, 5) + testdatetime = datetime.datetime(2021, 9, 3, 16, 19, 13) + testdatetime2 = datetime.datetime(2021, 9, 3, 17, 0, 1) + testdatetime_nextday = datetime.datetime(2021, 9, 4, 0, 2, 1) + + assert str(OrgDate(testdate)) == "<2021-09-03 Fri>" + assert str(OrgDate(testdatetime)) == "<2021-09-03 Fri 16:19>" + assert str(OrgDate(testdate, active=False)) == "[2021-09-03 Fri]" + assert str(OrgDate(testdatetime, active=False)) == "[2021-09-03 Fri 16:19]" + + assert str(OrgDate(testdate, testdate2)) == "<2021-09-03 Fri>--<2021-09-05 Sun>" + assert str(OrgDate(testdate, testdate2)) == "<2021-09-03 Fri>--<2021-09-05 Sun>" + assert str(OrgDate(testdatetime, testdatetime2)) == "<2021-09-03 Fri 16:19--17:00>" + assert str(OrgDate(testdate, testdate2, active=False)) == "[2021-09-03 Fri]--[2021-09-05 Sun]" + assert str(OrgDate(testdate, testdate2, active=False)) == "[2021-09-03 Fri]--[2021-09-05 Sun]" + assert str(OrgDate(testdatetime, testdatetime2, active=False)) == "[2021-09-03 Fri 16:19--17:00]" + + assert str(OrgDateScheduled(testdate)) == "<2021-09-03 Fri>" + assert str(OrgDateScheduled(testdatetime)) == "<2021-09-03 Fri 16:19>" + assert str(OrgDateDeadline(testdate)) == "<2021-09-03 Fri>" + assert str(OrgDateDeadline(testdatetime)) == "<2021-09-03 Fri 16:19>" + assert str(OrgDateClosed(testdate)) == "[2021-09-03 Fri]" + assert str(OrgDateClosed(testdatetime)) == "[2021-09-03 Fri 16:19]" + + assert str(OrgDateClock(testdatetime, testdatetime2)) == "[2021-09-03 Fri 16:19]--[2021-09-03 Fri 17:00]" + assert str(OrgDateClock(testdatetime, testdatetime_nextday)) == "[2021-09-03 Fri 16:19]--[2021-09-04 Sat 00:02]" + assert str(OrgDateClock(testdatetime)) == "[2021-09-03 Fri 16:19]" + + +def test_date_as_datetime() -> None: + testdate = (2021, 9, 3) + testdatetime = (2021, 9, 3, 16, 19, 13) + + assert OrgDate._as_datetime(datetime.date(*testdate)) == datetime.datetime(*testdate, 0, 0, 0) + assert OrgDate._as_datetime(datetime.datetime(*testdatetime)) == datetime.datetime(*testdatetime)
\ No newline at end of file diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/test_hugedata.py b/.venv/lib/python3.12/site-packages/orgparse/tests/test_hugedata.py new file mode 100644 index 00000000..f7248ca7 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/test_hugedata.py @@ -0,0 +1,30 @@ +import pickle + +from .. import loadi + + +def generate_org_lines(num_top_nodes, depth=3, nodes_per_level=1, _level=1): + if depth == 0: + return + for i in range(num_top_nodes): + yield ("*" * _level) + ' {0}-th heading of level {1}'.format(i, _level) + for child in generate_org_lines( + nodes_per_level, depth - 1, nodes_per_level, _level + 1): + yield child + + +def num_generate_org_lines(num_top_nodes, depth=3, nodes_per_level=1): + if depth == 0: + return 0 + return num_top_nodes * ( + 1 + num_generate_org_lines( + nodes_per_level, depth - 1, nodes_per_level)) + + +def test_picklable() -> None: + num = 1000 + depth = 3 + nodes_per_level = 1 + root = loadi(generate_org_lines(num, depth, nodes_per_level)) + assert sum(1 for _ in root) == num_generate_org_lines(num, depth, nodes_per_level) + 1 + pickle.dumps(root) # should not fail diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/test_misc.py b/.venv/lib/python3.12/site-packages/orgparse/tests/test_misc.py new file mode 100644 index 00000000..4cd73e4c --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/test_misc.py @@ -0,0 +1,299 @@ +from .. import load, loads +from ..node import OrgEnv +from orgparse.date import OrgDate + + +def test_empty_heading() -> None: + root = loads(''' +* TODO :sometag: + has no heading but still a todo? + it's a bit unclear, but seems to be highligted by emacs.. +''') + [h] = root.children + assert h.todo == 'TODO' + assert h.heading == '' + assert h.tags == {'sometag'} + + +def test_root() -> None: + root = loads(''' +#+STARTUP: hidestars +Whatever +# comment +* heading 1 + '''.strip()) + assert len(root.children) == 1 + # todo not sure if should strip special comments?? + assert root.body.endswith('Whatever\n# comment') + assert root.heading == '' + + +def test_stars(): + # https://github.com/karlicoss/orgparse/issues/7#issuecomment-533732660 + root = loads(""" +* Heading with text (A) + +The following line is not a heading, because it begins with a +star but has no spaces afterward, just a newline: + +* + +** Subheading with text (A1) + +*this_is_just* + + *some_bold_text* + +This subheading is a child of (A). + +The next heading has no text, but it does have a space after +the star, so it's a heading: + +* + +This text is under the "anonymous" heading above, which would be (B). + +** Subheading with text (B1) + +This subheading is a child of the "anonymous" heading (B), not of heading (A). + """) + [h1, h2] = root.children + assert h1.heading == 'Heading with text (A)' + assert h2.heading == '' + + +def test_parse_custom_todo_keys(): + todo_keys = ['TODO', 'CUSTOM1', 'ANOTHER_KEYWORD'] + done_keys = ['DONE', 'A'] + filename = '<string>' # default for loads + content = """ +* TODO Heading with a default todo keyword + +* DONE Heading with a default done keyword + +* CUSTOM1 Heading with a custom todo keyword + +* ANOTHER_KEYWORD Heading with a long custom todo keyword + +* A Heading with a short custom done keyword + """ + + env = OrgEnv(todos=todo_keys, dones=done_keys, filename=filename) + root = loads(content, env=env) + + assert root.env.all_todo_keys == ['TODO', 'CUSTOM1', + 'ANOTHER_KEYWORD', 'DONE', 'A'] + assert len(root.children) == 5 + assert root.children[0].todo == 'TODO' + assert root.children[1].todo == 'DONE' + assert root.children[2].todo == 'CUSTOM1' + assert root.children[3].todo == 'ANOTHER_KEYWORD' + assert root.children[4].todo == 'A' + + +def test_add_custom_todo_keys(): + todo_keys = ['CUSTOM_TODO'] + done_keys = ['CUSTOM_DONE'] + filename = '<string>' # default for loads + content = """#+TODO: COMMENT_TODO | COMMENT_DONE + """ + + env = OrgEnv(filename=filename) + env.add_todo_keys(todos=todo_keys, dones=done_keys) + + # check that only the custom keys are know before parsing + assert env.all_todo_keys == ['CUSTOM_TODO', 'CUSTOM_DONE'] + + # after parsing, all keys are set + root = loads(content, filename, env) + assert root.env.all_todo_keys == ['CUSTOM_TODO', 'COMMENT_TODO', + 'CUSTOM_DONE', 'COMMENT_DONE'] + +def test_get_file_property() -> None: + content = """#+TITLE: Test: title + * Node 1 + test 1 + * Node 2 + test 2 + """ + + # after parsing, all keys are set + root = loads(content) + assert root.get_file_property('Nosuchproperty') is None + assert root.get_file_property_list('TITLE') == ['Test: title'] + # also it's case insensitive + assert root.get_file_property('title') == 'Test: title' + assert root.get_file_property_list('Nosuchproperty') == [] + + +def test_get_file_property_multivalued() -> None: + content = """ #+TITLE: Test + #+OTHER: Test title + #+title: alternate title + + * Node 1 + test 1 + * Node 2 + test 2 + """ + + # after parsing, all keys are set + root = loads(content) + import pytest + + assert root.get_file_property_list('TITLE') == ['Test', 'alternate title'] + with pytest.raises(RuntimeError): + # raises because there are multiple of them + root.get_file_property('TITLE') + + +def test_filetags_are_tags() -> None: + content = ''' +#+FILETAGS: :f1:f2: + +* heading :h1: +** child :f2: + '''.strip() + root = loads(content) + # breakpoint() + assert root.tags == {'f1', 'f2'} + child = root.children[0].children[0] + assert child.tags == {'f1', 'f2', 'h1'} + + +def test_load_filelike() -> None: + import io + stream = io.StringIO(''' +* heading1 +* heading 2 +''') + root = load(stream) + assert len(root.children) == 2 + assert root.env.filename == '<file-like>' + + +def test_level_0_properties() -> None: + content = ''' +foo bar + +:PROPERTIES: +:PROP-FOO: Bar +:PROP-BAR: Bar bar +:END: + +* heading :h1: +:PROPERTIES: +:HEADING-PROP: foo +:END: +** child :f2: + '''.strip() + root = loads(content) + assert root.get_property('PROP-FOO') == 'Bar' + assert root.get_property('PROP-BAR') == 'Bar bar' + assert root.get_property('PROP-INVALID') is None + assert root.get_property('HEADING-PROP') is None + assert root.children[0].get_property('HEADING-PROP') == 'foo' + + +def test_level_0_timestamps() -> None: + content = ''' +foo bar + + - <2010-08-16 Mon> DateList + - <2010-08-07 Sat>--<2010-08-08 Sun> + - <2010-08-09 Mon 00:30>--<2010-08-10 Tue 13:20> RangeList + - <2019-08-10 Sat 16:30-17:30> TimeRange" + +* heading :h1: +** child :f2: + '''.strip() + root = loads(content) + assert root.datelist == [OrgDate((2010, 8, 16))] + assert root.rangelist == [ + OrgDate((2010, 8, 7), (2010, 8, 8)), + OrgDate((2010, 8, 9, 0, 30), (2010, 8, 10, 13, 20)), + OrgDate((2019, 8, 10, 16, 30, 0), (2019, 8, 10, 17, 30, 0)), + ] + +def test_date_with_cookies() -> None: + testcases = [ + ('<2010-06-21 Mon +1y>', + "OrgDate((2010, 6, 21), None, True, ('+', 1, 'y'))"), + ('<2005-10-01 Sat +1m>', + "OrgDate((2005, 10, 1), None, True, ('+', 1, 'm'))"), + ('<2005-10-01 Sat +1m -3d>', + "OrgDate((2005, 10, 1), None, True, ('+', 1, 'm'), ('-', 3, 'd'))"), + ('<2005-10-01 Sat -3d>', + "OrgDate((2005, 10, 1), None, True, None, ('-', 3, 'd'))"), + ('<2008-02-10 Sun ++1w>', + "OrgDate((2008, 2, 10), None, True, ('++', 1, 'w'))"), + ('<2008-02-08 Fri 20:00 ++1d>', + "OrgDate((2008, 2, 8, 20, 0, 0), None, True, ('++', 1, 'd'))"), + ('<2019-04-05 Fri 08:00 .+1h>', + "OrgDate((2019, 4, 5, 8, 0, 0), None, True, ('.+', 1, 'h'))"), + ('[2019-04-05 Fri 08:00 .+1h]', + "OrgDate((2019, 4, 5, 8, 0, 0), None, False, ('.+', 1, 'h'))"), + ('<2007-05-16 Wed 12:30 +1w>', + "OrgDate((2007, 5, 16, 12, 30, 0), None, True, ('+', 1, 'w'))"), + ] + for (input, expected) in testcases: + root = loads(input) + output = root[0].datelist[0] + assert str(output) == input + assert repr(output) == expected + testcases = [ + ('<2006-11-02 Thu 20:00-22:00 +1w>', + "OrgDate((2006, 11, 2, 20, 0, 0), (2006, 11, 2, 22, 0, 0), True, ('+', 1, 'w'))"), + ('<2006-11-02 Thu 20:00--22:00 +1w>', + "OrgDate((2006, 11, 2, 20, 0, 0), (2006, 11, 2, 22, 0, 0), True, ('+', 1, 'w'))"), + ] + for (input, expected) in testcases: + root = loads(input) + output = root[0].rangelist[0] + assert str(output) == "<2006-11-02 Thu 20:00--22:00 +1w>" + assert repr(output) == expected + # DEADLINE and SCHEDULED + testcases2 = [ + ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat +1m>', + "<2005-10-01 Sat +1m>", + "OrgDateDeadline((2005, 10, 1), None, True, ('+', 1, 'm'))"), + ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat +1m -3d>', + "<2005-10-01 Sat +1m -3d>", + "OrgDateDeadline((2005, 10, 1), None, True, ('+', 1, 'm'), ('-', 3, 'd'))"), + ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat -3d>', + "<2005-10-01 Sat -3d>", + "OrgDateDeadline((2005, 10, 1), None, True, None, ('-', 3, 'd'))"), + ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat ++1m>', + "<2005-10-01 Sat ++1m>", + "OrgDateDeadline((2005, 10, 1), None, True, ('++', 1, 'm'))"), + ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat .+1m>', + "<2005-10-01 Sat .+1m>", + "OrgDateDeadline((2005, 10, 1), None, True, ('.+', 1, 'm'))"), + ] + for (input, expected_str, expected_repr) in testcases2: + root = loads(input) + output = root[1].deadline + assert str(output) == expected_str + assert repr(output) == expected_repr + testcases2 = [ + ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat +1m>', + "<2005-10-01 Sat +1m>", + "OrgDateScheduled((2005, 10, 1), None, True, ('+', 1, 'm'))"), + ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat +1m -3d>', + "<2005-10-01 Sat +1m -3d>", + "OrgDateScheduled((2005, 10, 1), None, True, ('+', 1, 'm'), ('-', 3, 'd'))"), + ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat -3d>', + "<2005-10-01 Sat -3d>", + "OrgDateScheduled((2005, 10, 1), None, True, None, ('-', 3, 'd'))"), + ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat ++1m>', + "<2005-10-01 Sat ++1m>", + "OrgDateScheduled((2005, 10, 1), None, True, ('++', 1, 'm'))"), + ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat .+1m>', + "<2005-10-01 Sat .+1m>", + "OrgDateScheduled((2005, 10, 1), None, True, ('.+', 1, 'm'))"), + ] + for (input, expected_str, expected_repr) in testcases2: + root = loads(input) + output = root[1].scheduled + assert str(output) == expected_str + assert repr(output) == expected_repr diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/test_rich.py b/.venv/lib/python3.12/site-packages/orgparse/tests/test_rich.py new file mode 100644 index 00000000..7fb911b9 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/orgparse/tests/test_rich.py @@ -0,0 +1,89 @@ +''' +Tests for rich formatting: tables etc. +''' +from .. import load, loads +from ..extra import Table + +import pytest + + +def test_table() -> None: + root = loads(''' +| | | | +| | "heading" | | +| | | | +|-------+-----------+-----| +| reiwf | fef | | +|-------+-----------+-----| +|-------+-----------+-----| +| aba | caba | 123 | +| yeah | | X | + + |------------------------+-------| + | when | count | + | datetime | int | + |------------------------+-------| + | | -1 | + | [2020-11-05 Thu 23:44] | | + | [2020-11-06 Fri 01:00] | 1 | + |------------------------+-------| + +some irrelevant text + +| simple | +|--------| +| value1 | +| value2 | + ''') + + [gap1, t1, gap2, t2, gap3, t3, gap4] = root.body_rich + + t1 = Table(root._lines[1:10]) + t2 = Table(root._lines[11:19]) + t3 = Table(root._lines[22:26]) + + assert ilen(t1.blocks) == 4 + assert list(t1.blocks)[2] == [] + assert ilen(t1.rows) == 6 + + with pytest.raises(RuntimeError): + list(t1.as_dicts) # not sure what should it be + + assert ilen(t2.blocks) == 2 + assert ilen(t2.rows) == 5 + assert list(t2.rows)[3] == ['[2020-11-05 Thu 23:44]', ''] + + + assert ilen(t3.blocks) == 2 + assert list(t3.rows) == [['simple'], ['value1'], ['value2']] + assert t3.as_dicts.columns == ['simple'] + assert list(t3.as_dicts) == [{'simple': 'value1'}, {'simple': 'value2'}] + + +def test_table_2() -> None: + root = loads(''' +* item + +#+tblname: something +| date | value | comment | +|----------------------+-------+-------------------------------| +| 14.04.17 | 11 | aaaa | +| May 26 2017 08:00 | 12 | what + about + pluses? | +| May 26 09:00 - 10:00 | 13 | time is | + + some comment + +#+BEGIN_SRC python :var fname="plot.png" :var table=something :results file +fig.savefig(fname) +return fname +#+END_SRC + +#+RESULTS: +[[file:plot.png]] +''') + [_, t, _] = root.children[0].body_rich + assert ilen(t.as_dicts) == 3 + + +def ilen(x) -> int: + return len(list(x)) |