aboutsummaryrefslogtreecommitdiff
path: root/.venv/lib/python3.12/site-packages/orgparse
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/orgparse')
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/__init__.py166
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/date.py717
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/extra.py116
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/inline.py48
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/node.py1459
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/py.typed0
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/__init__.py0
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.org17
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.py33
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.org29
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.py73
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.org31
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.py44
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.org5
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.py13
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.org7
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.py11
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.org9
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.py23
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/__init__.py0
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/test_data.py154
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/test_date.py42
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/test_hugedata.py30
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/test_misc.py299
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/test_rich.py89
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))