This repository has no description
Something went wrong. Try again.
44 kB · 1473 lines
Python
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474from __future__ import annotations
import itertoolsimport refrom collections.abc import Iterable, Iterator, Sequencefrom typing import ( Any, Optional, Union, cast,)
from .date import ( OrgDate, OrgDateClock, OrgDateClosed, OrgDateDeadline, OrgDateRepeatedTask, OrgDateScheduled, parse_sdc,)from .extra import Rich, to_rich_textfrom .inline import to_plain_text
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: str) -> tuple[str, int] | None: """ 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
""" m = RE_HEADING_STARS.search(heading) if m is not None: return (m.group(2), len(m.group(1))) return None
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.htmlRE_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(f"Invalid duration format {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'({})'.format(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_RERE_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 = rf'(?:[ \t]*{ORG_DURATION_UNIT_RE})+[ \t]*'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 = rf'(?P<A>([ \t]*{ORG_DURATION_UNIT_RE})+)[ \t]*(?P<B>[0-9]+(?::[0-9][0-9]){{1,2}})[ \t]*'RE_ORG_DURATION_MIXED = re.compile(ORG_DURATION_MIXED_RE)# Regexp matching float numbers.RE_FLOAT = re.compile(r'[0-9]+([.][0-9]*)?')
# -> Optional[Tuple[str, Sequence[str]]]: # todo wtf?? it says 'ABCMeta isn't subscriptable??'def parse_comment(line: str): """ 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: """ Information global to the file (e.g, TODO keywords). """
def __init__( self, todos: Sequence[str] | None = None, dones: Sequence[str] | None = None, filename: str = '<undefined>', ) -> None: if dones is None: dones = ['DONE'] if todos is None: todos = ['TODO'] self._todos = list(todos) self._dones = list(dones) self._todo_not_specified_in_comment = True self._filename = filename self._nodes: list[OrgBaseNode] = []
@property def nodes(self) -> list[OrgBaseNode]: """ 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) -> str: """ 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>``. """ 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: OrgEnv, index: int | None = None) -> None: 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) -> int: return sum(1 for _ in self)
def __bool__(self) -> bool: # As self.__len__ returns non-zero value always this is not # needed. This function is only for performance. return True
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(f"Out of range {key}") else: raise TypeError(f"Inappropriate type {type(key)} for {type(self)}")
# tree structure
def _find_same_level(self, iterable) -> OrgBaseNode | None: for node in iterable: if node.level < self.level: return None if node.level == self.level: return node return None
@property def previous_same_level(self) -> OrgBaseNode | None: """ 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) -> OrgBaseNode | None: """ 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 return None
def get_parent(self, max_level: int | None = 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) -> int: """ Level of this node. """ raise NotImplementedError
def _get_tags(self, *, inher: bool = False) -> set[str]: # noqa: ARG002 """ Return tags
:arg inher: Mix with tags of all ancestor nodes if ``True``. """ 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: str = 'plain'): # noqa: A002 if format == 'plain': return to_plain_text(text) elif format == 'raw': return text elif format == 'rich': return to_rich_text(text) else: raise ValueError(f'format={format} is not supported.')
def get_body(self, format: str = 'plain') -> str: # noqa: A002 """ 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): # noqa: FBT002,A002 # will fix later """ 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: str): # noqa: A002 """ 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: str): # noqa: A002 """ 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(f'Multiple values for property {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: bool = False) -> set[str]: # noqa: ARG002 filetags = self.get_file_property_list('FILETAGS') return set(filetags)
@property def level(self) -> int: return 0
def get_parent(self, max_level=None): # noqa: ARG002 return None
def is_root(self) -> bool: 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().__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: int | None = 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_level = parse_heading_level(heading) if heading_level is not None: (heading, self._level) = heading_level (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: str = 'plain') -> str: # noqa: A002 """ 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): """ Level attribute of this node. Top level node is level 1.
>>> from orgparse import loads >>> root = loads(''' ... * Node 1 ... ** Node 2 ... ''') >>> (n1, n2) = list(root[1:]) >>> root.level 0 >>> n1.level 1 >>> n2.level 2
""" return self._level
@property def priority(self) -> str | None: """ 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: bool = 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