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.
Projectkarlicoss/orgparse
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
| 1 | 1 | import re | |
| 2 | 2 | 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
fail→pass·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.