Whole file

karlicoss/orgparse

The author described this change as fix for parsing empty heading. It counts as a record because the check below fails on the code as it stood at 18a836b34 and passes on 362f0865b, with nothing else changed between the two runs.

Fix saved2020-11-01
Sharing licenceBSD-2-Clause · LICENSE
Change size+989 988

What the code was meant to do, written into the code itself as a save note

fix for parsing empty heading

The change

11 import re
22 import itertools
3-from typing import List, Iterable, Iterator, Optional, Union, Tuple, cast, Dict
4-try:
5- from collections.abc import Sequence
6-except ImportError:
7- from collections import Sequence
8-
9-from .date import OrgDate, OrgDateClock, OrgDateRepeatedTask, parse_sdc
10-from .inline import to_plain_text
11-from .utils.py3compat import PY3, unicode
12-
13-
14-def lines_to_chunks(lines: Iterable[str]) -> Iterable[List[str]]:
15- chunk: List[str] = []
16- for l in lines:
17- if RE_NODE_HEADER.search(l):
18- yield chunk
19- chunk = []
20- chunk.append(l)
21- yield chunk
22-
23-RE_NODE_HEADER = re.compile(r"^\*+ ")
24-
25-
26-def parse_heading_level(heading):
27- """
28- Get star-stripped heading and its level
29-
30- >>> parse_heading_level('* Heading')
31- ('Heading', 1)
32- >>> parse_heading_level('******** Heading')
33- ('Heading', 8)
34- >>> parse_heading_level('*') # None since no space after star
35- >>> parse_heading_level('*bold*') # None
36- >>> parse_heading_level('not heading') # None
37-
38- """
39- match = RE_HEADING_STARS.search(heading)
40- if match:
41- return (match.group(2), len(match.group(1)))
42-
43-RE_HEADING_STARS = re.compile(r'^(\*+)\s+(.*?)\s*$')
44-
45-
46-def parse_heading_tags(heading: str) -> Tuple[str, List[str]]:
47- """
48- Get first tags and heading without tags
49-
50- >>> parse_heading_tags('HEADING')
51- ('HEADING', [])
52- >>> parse_heading_tags('HEADING :TAG1:TAG2:')
53- ('HEADING', ['TAG1', 'TAG2'])
54- >>> parse_heading_tags('HEADING: this is still heading :TAG1:TAG2:')
55- ('HEADING: this is still heading', ['TAG1', 'TAG2'])
56- >>> parse_heading_tags('HEADING :@tag:_tag_:')
57- ('HEADING', ['@tag', '_tag_'])
58-
59- Here is the spec of tags from Org Mode manual:
60-
61- Tags are normal words containing letters, numbers, ``_``, and
62- ``@``. Tags must be preceded and followed by a single colon,
63- e.g., ``:work:``.
64-
65- -- (info "(org) Tags")
66-
67- """
68- match = RE_HEADING_TAGS.search(heading)
69- if match:
70- heading = match.group(1)
71- tagstr = match.group(2)
72- tags = tagstr.split(':')
73- else:
74- tags = []
75- return (heading, tags)
76-
77-# Tags are normal words containing letters, numbers, '_', and '@'. https://orgmode.org/manual/Tags.html
78-RE_HEADING_TAGS = re.compile(r'(.*?)\s*:([\w@:]+):\s*$')
79-
80-
81-def parse_heading_todos(heading, todo_candidates):
82- """
83- Get TODO keyword and heading without TODO keyword.
84-
85- >>> todos = ['TODO', 'DONE']
86- >>> parse_heading_todos('Normal heading', todos)
87- ('Normal heading', None)
88- >>> parse_heading_todos('TODO Heading', todos)
89- ('Heading', 'TODO')
90-
91- """
92- for todo in todo_candidates:
93- todows = '{0} '.format(todo)
94- if heading.startswith(todows):
95- return (heading[len(todows):], todo)
96- return (heading, None)
97-
98-
99-def parse_heading_priority(heading):
100- """
101- Get priority and heading without priority field.
102-
103- >>> parse_heading_priority('HEADING')
104- ('HEADING', None)
105- >>> parse_heading_priority('[#A] HEADING')
106- ('HEADING', 'A')
107- >>> parse_heading_priority('[#0] HEADING')
108- ('HEADING', '0')
109- >>> parse_heading_priority('[#A]')
110- ('', 'A')
111-
112- """
113- match = RE_HEADING_PRIORITY.search(heading)
114- if match:
115- return (match.group(2), match.group(1))
116- else:
117- return (heading, None)
118-
119-RE_HEADING_PRIORITY = re.compile(r'^\s*\[#([A-Z0-9])\] ?(.*)$')
120-
121-PropertyValue = Union[str, int]
122-def parse_property(line: str) -> Tuple[Optional[str], Optional[PropertyValue]]:
123- """
124- Get property from given string.
125-
126- >>> parse_property(':Some_property: some value')
127- ('Some_property', 'some value')
128- >>> parse_property(':Effort: 1:10')
129- ('Effort', 70)
130-
131- """
132- prop_key = None
133- prop_val: Optional[Union[str, int]] = None
134- match = RE_PROP.search(line)
135- if match:
136- prop_key = match.group(1)
137- prop_val = match.group(2)
138- if prop_key == 'Effort':
139- (h, m) = prop_val.split(":", 2)
140- if h.isdigit() and m.isdigit():
141- prop_val = int(h) * 60 + int(m)
142- return (prop_key, prop_val)
143-
144-RE_PROP = re.compile(r'^\s*:(.*?):\s*(.*?)\s*$')
145-
146-
147-def parse_comment(line):
148- """
149- Parse special comment such as ``#+SEQ_TODO``
150-
151- >>> parse_comment('#+SEQ_TODO: TODO | DONE')
152- ('SEQ_TODO', 'TODO | DONE')
153- >>> parse_comment('# not a special comment') # None
154-
155- """
156- if line.startswith('#+'):
157- comment = line.lstrip('#+').split(':', 1)
158- if len(comment) == 2:
159- return (comment[0], comment[1].strip())
160-
161-
162-def parse_seq_todo(line):
163- """
164- Parse value part of SEQ_TODO/TODO/TYP_TODO comment.
165-
166- >>> parse_seq_todo('TODO | DONE')
167- (['TODO'], ['DONE'])
168- >>> parse_seq_todo(' Fred Sara Lucy Mike | DONE ')
169- (['Fred', 'Sara', 'Lucy', 'Mike'], ['DONE'])
170- >>> parse_seq_todo('| CANCELED')
171- ([], ['CANCELED'])
172- >>> parse_seq_todo('REPORT(r) BUG(b) KNOWNCAUSE(k) | FIXED(f)')
173- (['REPORT', 'BUG', 'KNOWNCAUSE'], ['FIXED'])
174-
175- See also:
176-
177- * (info "(org) Per-file keywords")
178- * (info "(org) Fast access to TODO states")
179-
180- """
181- todo_done = line.split('|', 1)
182- if len(todo_done) == 2:
183- (todos, dones) = todo_done
184- else:
185- (todos, dones) = (line, '')
186- strip_fast_access_key = lambda x: x.split('(', 1)[0]
187- return (list(map(strip_fast_access_key, todos.split())),
188- list(map(strip_fast_access_key, dones.split())))
189-
190-
191-class OrgEnv(object):
192-
193- """
194- Information global to the file (e.g, TODO keywords).
195- """
196-
197- def __init__(self, todos=['TODO'], dones=['DONE'],
198- filename='<undefined>'):
199- self._todos = list(todos)
200- self._dones = list(dones)
201- self._todo_not_specified_in_comment = True
202- self._filename = filename
203- self._nodes = []
204-
205- @property
206- def nodes(self):
207- """
208- A list of org nodes.
209-
210- >>> OrgEnv().nodes # default is empty (of course)
211- []
212-
213- >>> from orgparse import loads
214- >>> loads('''
215- ... * Heading 1
216- ... ** Heading 2
217- ... *** Heading 3
218- ... ''').env.nodes # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
219- [<orgparse.node.OrgRootNode object at 0x...>,
220- <orgparse.node.OrgNode object at 0x...>,
221- <orgparse.node.OrgNode object at 0x...>,
222- <orgparse.node.OrgNode object at 0x...>]
223-
224- """
225- return self._nodes
226-
227- def add_todo_keys(self, todos, dones):
228- if self._todo_not_specified_in_comment:
229- self._todos = []
230- self._dones = []
231- self._todo_not_specified_in_comment = False
232- self._todos.extend(todos)
233- self._dones.extend(dones)
234-
235- @property
236- def todo_keys(self):
237- """
238- TODO keywords defined for this document (file).
239-
240- >>> env = OrgEnv()
241- >>> env.todo_keys
242- ['TODO']
243-
244- """
245- return self._todos
246-
247- @property
248- def done_keys(self):
249- """
250- DONE keywords defined for this document (file).
251-
252- >>> env = OrgEnv()
253- >>> env.done_keys
254- ['DONE']
255-
256- """
257- return self._dones
258-
259- @property
260- def all_todo_keys(self):
261- """
262- All TODO keywords (including DONEs).
263-
264- >>> env = OrgEnv()
265- >>> env.all_todo_keys
266- ['TODO', 'DONE']
267-
268- """
269- return self._todos + self._dones
270-
271- @property
272- def filename(self):
273- """
274- Return a path to the source file or similar information.
275-
276- If the org objects are not loaded from a file, this value
277- will be a string of the form ``<SOME_TEXT>``.
278-
279- :rtype: str
280-
281- """
282- return self._filename
283-
284- # parser
285-
286- def from_chunks(self, chunks):
287- yield OrgRootNode.from_chunk(self, next(chunks))
288- for chunk in chunks:
289- yield OrgNode.from_chunk(self, chunk)
290-
291-
292-class OrgBaseNode(Sequence):
293-
294- """
295- Base class for :class:`OrgRootNode` and :class:`OrgNode`
296-
297- .. attribute:: env
298-
299- An instance of :class:`OrgEnv`.
300- All nodes in a same file shares same instance.
301-
302- :class:`OrgBaseNode` is an iterable object:
303-
304- >>> from orgparse import loads
305- >>> root = loads('''
306- ... * Heading 1
307- ... ** Heading 2
308- ... *** Heading 3
309- ... * Heading 4
310- ... ''')
311- >>> for node in root:
312- ... print(node)
313- <BLANKLINE>
314- * Heading 1
315- ** Heading 2
316- *** Heading 3
317- * Heading 4
318-
319- Note that the first blank line is due to the root node, as
320- iteration contains the object itself. To skip that, use
321- slice access ``[1:]``:
322-
323- >>> for node in root[1:]:
324- ... print(node)
325- * Heading 1
326- ** Heading 2
327- *** Heading 3
328- * Heading 4
329-
330- It also supports sequence protocol.
331-
332- >>> print(root[1])
333- * Heading 1
334- >>> root[0] is root # index 0 means itself
335- True
336- >>> len(root) # remember, sequence contains itself
337- 5
338-
339- Note the difference between ``root[1:]`` and ``root[1]``:
340-
341- >>> for node in root[1]:
342- ... print(node)
343- * Heading 1
344- ** Heading 2
345- *** Heading 3
346-
347- Nodes remember the line number information (1-indexed):
348-
349- >>> print(root.children[1].linenumber)
350- 5
351- """
352-
353- def __init__(self, env, index=None) -> None:
354- """
355- Create an :class:`OrgBaseNode` object.
356-
357- :type env: :class:`OrgEnv`
358- :arg env: This will be set to the :attr:`env` attribute.
359-
360- """
361- self.env = env
362-
363- self.linenumber = cast(int, None) # set in parse_lines
364-
365- # content
366- self._lines: List[str] = []
367-
368- # FIXME: use `index` argument to set index. (Currently it is
369- # done externally in `parse_lines`.)
370- if index is not None:
371- self._index = index
372- """
373- Index of `self` in `self.env.nodes`.
374-
375- It must satisfy an equality::
376-
377- self.env.nodes[self._index] is self
378-
379- This value is used for quick access for iterator and
380- tree-like traversing.
381-
382- """
383-
384- def __iter__(self):
385- yield self
386- level = self.level
387- for node in self.env._nodes[self._index + 1:]:
388- if node.level > level:
389- yield node
390- else:
391- break
392-
393- def __len__(self):
394- return sum(1 for _ in self)
395-
396- def __nonzero__(self):
397- # As self.__len__ returns non-zero value always this is not
398- # needed. This function is only for performance.
399- return True
400-
1582 further changed lines not shown

The check that tells the two apart

failpass·orgparse/tests/test_misc.py::test_empty_heading

Check file orgparse/tests/test_misc.py, taken without changes from the fix and copied onto the older code, so the exact same check runs against both versions.

Origin and history

The code before it18a836b34e9304c9dff7ddf6c7e3c9311d74748b
Broken version dated2020-11-01
Moduleorgparse.node
Units changedOrgBaseNode, OrgNode, parse_heading_todos
Fingerprint06fd2c9127c491b9
Checked2026-08-18 by goldset/0.1

Every field above is generated by our program. None of it is written by hand.

Other bugs found in karlicoss/orgparse