From 5ed34df57a597eb2a334bf9d4d4d886dbad0a613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 15 Oct 2023 16:34:19 +0200 Subject: [PATCH 01/55] Make typed functions pass `mypy` check. --- org_rw/dom.py | 14 ++++++ org_rw/org_rw.py | 124 ++++++++++++++++++++++++++++------------------- org_rw/types.py | 17 +++++++ 3 files changed, 104 insertions(+), 51 deletions(-) create mode 100644 org_rw/types.py diff --git a/org_rw/dom.py b/org_rw/dom.py index cb3d8fd..36493f6 100644 --- a/org_rw/dom.py +++ b/org_rw/dom.py @@ -1,3 +1,6 @@ +from typing import Union + + class DrawerNode: def __init__(self): self.children = [] @@ -102,4 +105,15 @@ class CodeBlock(BlockNode): def __repr__(self): return "".format(len(self.lines)) +DomNode = Union[DrawerNode, + PropertyNode, + ListGroupNode, + TableNode, + TableSeparatorRow, + TableRow, + Text, + ListItem, + BlockNode, + ] + from .utils import get_raw_contents diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 9a60199..b42e889 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1,6 +1,7 @@ from __future__ import annotations import collections +from ctypes import ArgumentError import difflib import logging import os @@ -8,7 +9,9 @@ import re import sys from datetime import date, datetime, timedelta from enum import Enum -from typing import Generator, List, Optional, Tuple, Union +from typing import cast, Iterator, List, Optional, Tuple, Union + +from .types import HeadlineDict from . import dom @@ -154,12 +157,12 @@ class RangeInRaw: contents.insert(start_idx + i + 1, element) -def unescape_block_lines(lines: str) -> str: +def unescape_block_lines(block: str) -> str: """ Remove leading ',' from block_lines if they escape `*` characters. """ i = 0 - lines = lines.split('\n') + lines = block.split('\n') while i < len(lines): line = lines[i] if (line.lstrip(' ').startswith(',') @@ -177,8 +180,8 @@ def unescape_block_lines(lines: str) -> str: def get_links_from_content(content): in_link = False in_description = False - link_value = [] - link_description = [] + link_value: List[str] = [] + link_description: List[str] = [] for i, tok in enumerate(get_tokens(content)): if isinstance(tok, LinkToken): @@ -210,8 +213,8 @@ def text_to_dom(tokens, item): in_link = False in_description = False - link_value = [] - link_description = [] + link_value: List[str] = [] + link_description: List[str] = [] contents = [] @@ -361,9 +364,10 @@ class Headline: + self.delimiters ) - tree = [] - current_node = None - indentation_tree = [] + tree: List[dom.DomNode] = [] + current_node: Optional[dom.DomNode] = None + indentation_tree: List[dom.DomNode] = [] + contents: Optional[str] = None for line in sorted(everything, key=get_line): if isinstance(current_node, dom.CodeBlock): @@ -404,7 +408,7 @@ class Headline: ): node.append(dom.Text(line)) current_node = node - contents = [] + contents = None break elif ((not isinstance(node, dom.TableNode)) and (type(node) not in NON_FINISHED_GROUPS) @@ -419,7 +423,7 @@ class Headline: tree_up.pop(-1) else: current_node = None - contents = [] + contents = None tree.append(dom.Text(text_to_dom(line.contents, line))) indentation_tree = tree_up @@ -669,7 +673,9 @@ class Headline: parsed = as_time_range else: parsed = OrgTime.parse(time_seg) - times.append(parsed) + + if parsed is not None: + times.append(parsed) return times @@ -1130,6 +1136,9 @@ def parse_time(value: str) -> Union[None, TimeRange, OrgTime]: # @TODO properly consider "=> DURATION" section start, end = value.split("=")[0].split("--") as_time_range = parse_org_time_range(start, end) + if as_time_range is None: + return None + if (as_time_range.start_time is not None) and ( as_time_range.end_time is not None ): @@ -1142,8 +1151,13 @@ def parse_time(value: str) -> Union[None, TimeRange, OrgTime]: return None -def parse_org_time_range(start, end) -> TimeRange: - return TimeRange(OrgTime.parse(start), OrgTime.parse(end)) +def parse_org_time_range(start, end) -> Optional[TimeRange]: + start_time = OrgTime.parse(start) + end_time = OrgTime.parse(end) + + if start_time is None or end_time is None: + return None + return TimeRange(start_time, end_time) class OrgTime: @@ -1170,12 +1184,13 @@ class OrgTime: return f"OrgTime({self.to_raw()})" @classmethod - def parse(self, value: str) -> OrgTime: + def parse(self, value: str) -> Optional[OrgTime]: if m := ACTIVE_TIME_STAMP_RE.match(value): active = True elif m := INACTIVE_TIME_STAMP_RE.match(value): active = False else: + # raise ArgumentError("Cannot parse `{}` as OrgTime".format(value)) return None repetition = None @@ -1219,7 +1234,7 @@ class OrgTime: ) -def time_from_str(s: str) -> OrgTime: +def time_from_str(s: str) -> Optional[OrgTime]: return OrgTime.parse(s) @@ -1280,7 +1295,7 @@ class Line: class Link: - def __init__(self, value: str, description: str, origin: RangeInRaw): + def __init__(self, value: str, description: Optional[str], origin: RangeInRaw): self._value = value self._description = description self._origin = origin @@ -1452,7 +1467,7 @@ class Verbatim: return f"{self.Marker}{raw}{self.Marker}" -def is_pre(char: str) -> bool: +def is_pre(char: Optional[str]) -> bool: if isinstance(char, str): return char in "\n\r\t -({'\"" else: @@ -1499,7 +1514,7 @@ def tokenize_contents(contents: str): tokens = [] last_char = None - text = [] + text: List[str] = [] closes = set() in_link = False in_link_description = False @@ -1619,7 +1634,7 @@ def parse_contents(raw_contents: List[RawLine]): return [] blocks = [] - current_block = [] + current_block: List[RawLine] = [] for line in raw_contents: if len(current_block) == 0: @@ -1627,6 +1642,7 @@ def parse_contents(raw_contents: List[RawLine]): current_line = line.linenum current_block.append(line) else: + current_line = cast(int, current_line) if line.linenum == current_line + 1: # Continue with the current block current_line = line.linenum @@ -1652,8 +1668,8 @@ def parse_content_block(raw_contents: Union[List[RawLine],str]): for line in raw_contents: contents_buff.append(line.line) - contents = "\n".join(contents_buff) - tokens = tokenize_contents(contents) + contents_buff_text = "\n".join(contents_buff) + tokens = tokenize_contents(contents_buff_text) if isinstance(raw_contents, str): current_line = None else: @@ -1893,7 +1909,7 @@ class OrgDoc: def getTopHeadlines(self): return self.headlines - def getAllHeadlines(self) -> Generator[Headline]: + def getAllHeadlines(self) -> Iterator[Headline]: todo = self.headlines[::-1] # We go backwards, to pop/append and go depth-first while len(todo) != 0: hl = todo.pop() @@ -2016,15 +2032,16 @@ class OrgDoc: class OrgDocReader: def __init__(self): - self.headlines: List[Headline] = [] - self.keywords: List[Property] = [] - self.headline_hierarchy: List[OrgDoc] = [] + self.headlines: List[HeadlineDict] = [] + self.keywords: List[Keyword] = [] + self.headline_hierarchy: List[HeadlineDict] = [] self.contents: List[RawLine] = [] self.delimiters: List[DelimiterLine] = [] self.list_items: List[ListItem] = [] self.table_rows: List[TableRow] = [] self.structural: List = [] self.properties: List = [] + self.current_drawer: Optional[List] = None def finalize(self): return OrgDoc( @@ -2037,12 +2054,12 @@ class OrgDocReader: ) ## Construction - def add_headline(self, linenum: int, match: re.Match) -> int: + def add_headline(self, linenum: int, match: re.Match): # Position reader on the proper headline stars = match.group("stars") depth = len(stars) - headline = { + headline: HeadlineDict = { "linenum": linenum, "orig": match, "title": match.group("line"), @@ -2058,27 +2075,35 @@ class OrgDocReader: "table_rows": [], } - while (depth - 1) > len(self.headline_hierarchy): + headline_hierarchy: List[Optional[HeadlineDict]] = list(self.headline_hierarchy) + + while (depth - 1) > len(headline_hierarchy): # Introduce structural headlines - self.headline_hierarchy.append(None) - while depth <= len(self.headline_hierarchy): - self.headline_hierarchy.pop() + headline_hierarchy.append(None) + while depth <= len(headline_hierarchy): + headline_hierarchy.pop() if depth == 1: self.headlines.append(headline) else: - parent_idx = len(self.headline_hierarchy) - 1 - while self.headline_hierarchy[parent_idx] is None: + parent_idx = len(headline_hierarchy) - 1 + while headline_hierarchy[parent_idx] is None: parent_idx -= 1 - self.headline_hierarchy[parent_idx]["children"].append(headline) - self.headline_hierarchy.append(headline) + parent_headline = headline_hierarchy[parent_idx] + assert parent_headline is not None + parent_headline["children"].append(headline) + headline_hierarchy.append(headline) - if all([hl is not None for hl in self.headline_hierarchy]): + if all([hl is not None for hl in headline_hierarchy]): if not ([ len(hl['orig'].group('stars')) for hl in self.headline_hierarchy ] == list(range(1, len(self.headline_hierarchy) + 1))): raise AssertionError('Error on Headline Hierarchy') + else: + raise AssertionError('None found on headline hierarchy') - def add_list_item_line(self, linenum: int, match: re.Match) -> int: + self.headline_hierarchy = cast(List[HeadlineDict], headline_hierarchy) + + def add_list_item_line(self, linenum: int, match: re.Match) -> ListItem: li = ListItem( linenum=linenum, match=match, @@ -2103,7 +2128,7 @@ class OrgDocReader: self.headline_hierarchy[-1]["list_items"].append(li) return li - def add_table_line(self, linenum: int, line: str) -> int: + def add_table_line(self, linenum: int, line: str): chunks = line.split('|') indentation = len(chunks[0]) if chunks[-1].strip() == '': @@ -2128,7 +2153,7 @@ class OrgDocReader: else: self.headline_hierarchy[-1]["table_rows"].append(row) - def add_keyword_line(self, linenum: int, match: re.Match) -> int: + def add_keyword_line(self, linenum: int, match: re.Match): options = match.group("options") kw = Keyword( linenum, @@ -2188,22 +2213,19 @@ class OrgDocReader: else: self.headline_hierarchy[-1]["structural"].append((linenum, line)) - def add_node_properties_line(self, linenum: int, match: re.Match) -> int: + def add_node_properties_line(self, linenum: int, match: re.Match): key = match.group("key") value = match.group("value").strip() if as_time := parse_time(value): value = as_time - try: - self.current_drawer.append(Property(linenum, match, key, value, None)) - except Exception: - if "current_drawer" not in dir(self): # Throw a better error on this case - raise Exception( - "Found properties before :PROPERTIES: line. Error on Org file?" - ) - else: - raise # Let the exception pass + if self.current_drawer is None: # Throw a better error on this case + raise Exception( + "Found properties before :PROPERTIES: line. Error on Org file?" + ) + + self.current_drawer.append(Property(linenum, match, key, value, None)) def read(self, s, environment): lines = s.split("\n") diff --git a/org_rw/types.py b/org_rw/types.py new file mode 100644 index 0000000..eff7f59 --- /dev/null +++ b/org_rw/types.py @@ -0,0 +1,17 @@ +import re +from typing import List, TypedDict + +class HeadlineDict(TypedDict): + linenum: int + orig: re.Match + title: str + contents: List + children: List + keywords: List + properties: List + logbook: List + structural: List + delimiters: List + results: List # TODO: Move to each specific code block? + list_items: List + table_rows: List From 343d864559ae439711b8e55c5288262503da6c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 15 Oct 2023 16:38:00 +0200 Subject: [PATCH 02/55] Fix handling of headline title-tags separated by tabs. --- org_rw/org_rw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index b42e889..318c4df 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1934,7 +1934,7 @@ class OrgDoc: raw_title = token_list_to_raw(headline.title.contents) tags_padding = "" - if not raw_title.endswith(" ") and tags: + if not (raw_title.endswith(" ") or raw_title.endswith("\t")) and tags: tags_padding = " " yield "*" * headline.depth + headline.spacing + state + raw_title + tags_padding + tags From f7ddddb8c950007137878a400c8d04bde6102a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 15 Oct 2023 17:54:59 +0200 Subject: [PATCH 03/55] Add PyTest Gitea action. --- .gitea/workflows/pytest.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .gitea/workflows/pytest.yaml diff --git a/.gitea/workflows/pytest.yaml b/.gitea/workflows/pytest.yaml new file mode 100644 index 0000000..8839413 --- /dev/null +++ b/.gitea/workflows/pytest.yaml @@ -0,0 +1,14 @@ +name: Pytest +# run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀 +on: [push] + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - run: apt-get update && apt-get install -y python3-pip + - run: pip install -e . + - run: pip install pytest + - run: pytest From da1288a6ba5535d31acd904d0622a605c09ba1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 15 Oct 2023 23:34:29 +0200 Subject: [PATCH 04/55] Add MyPy Gitea action. --- .gitea/workflows/mypy.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .gitea/workflows/mypy.yaml diff --git a/.gitea/workflows/mypy.yaml b/.gitea/workflows/mypy.yaml new file mode 100644 index 0000000..3c1604d --- /dev/null +++ b/.gitea/workflows/mypy.yaml @@ -0,0 +1,14 @@ +name: Mypy +# run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀 +on: [push] + +jobs: + mypy: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - run: apt-get update && apt-get install -y python3-pip + - run: pip install -e . + - run: pip install mypy + - run: mypy org_rw From 61246da52170fbbb3ec95b8519189c265ce7fad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 15 Oct 2023 23:37:45 +0200 Subject: [PATCH 05/55] Merge MyPy and Pytest Gitea actions. --- .gitea/workflows/mypy.yaml | 14 -------------- .gitea/workflows/pytest.yaml | 12 +++++++++++- 2 files changed, 11 insertions(+), 15 deletions(-) delete mode 100644 .gitea/workflows/mypy.yaml diff --git a/.gitea/workflows/mypy.yaml b/.gitea/workflows/mypy.yaml deleted file mode 100644 index 3c1604d..0000000 --- a/.gitea/workflows/mypy.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: Mypy -# run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀 -on: [push] - -jobs: - mypy: - runs-on: ubuntu-latest - steps: - - name: Check out repository code - uses: actions/checkout@v3 - - run: apt-get update && apt-get install -y python3-pip - - run: pip install -e . - - run: pip install mypy - - run: mypy org_rw diff --git a/.gitea/workflows/pytest.yaml b/.gitea/workflows/pytest.yaml index 8839413..fe4b961 100644 --- a/.gitea/workflows/pytest.yaml +++ b/.gitea/workflows/pytest.yaml @@ -1,4 +1,4 @@ -name: Pytest +name: Testing # run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀 on: [push] @@ -12,3 +12,13 @@ jobs: - run: pip install -e . - run: pip install pytest - run: pytest + + mypy: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - run: apt-get update && apt-get install -y python3-pip + - run: pip install -e . + - run: pip install mypy + - run: mypy org_rw From 9fb4bce5ef40d379cb2e98922e7ad7f68a021bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 15 Oct 2023 23:41:20 +0200 Subject: [PATCH 06/55] Add extra-test validation. --- .gitea/workflows/{pytest.yaml => tests.yaml} | 9 +++++++++ 1 file changed, 9 insertions(+) rename .gitea/workflows/{pytest.yaml => tests.yaml} (69%) diff --git a/.gitea/workflows/pytest.yaml b/.gitea/workflows/tests.yaml similarity index 69% rename from .gitea/workflows/pytest.yaml rename to .gitea/workflows/tests.yaml index fe4b961..2246ad6 100644 --- a/.gitea/workflows/pytest.yaml +++ b/.gitea/workflows/tests.yaml @@ -22,3 +22,12 @@ jobs: - run: pip install -e . - run: pip install mypy - run: mypy org_rw + + stability-extra-test: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - run: apt-get update && apt-get install -y git-core python3-pip + - run: pip install -e . + - run: bash extra-tests/check_all.sh From 1d0b4cce14c27ad30937c7aeadcf7e8ae5080670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Mon, 16 Oct 2023 00:21:30 +0200 Subject: [PATCH 07/55] Complete typing with `mypy --check-untyped-defs`. --- .gitea/workflows/tests.yaml | 2 +- org_rw/dom.py | 12 +++++++++--- org_rw/org_rw.py | 26 ++++++++++++++++++-------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/tests.yaml b/.gitea/workflows/tests.yaml index 2246ad6..ee49a29 100644 --- a/.gitea/workflows/tests.yaml +++ b/.gitea/workflows/tests.yaml @@ -21,7 +21,7 @@ jobs: - run: apt-get update && apt-get install -y python3-pip - run: pip install -e . - run: pip install mypy - - run: mypy org_rw + - run: mypy org_rw --check-untyped-defs stability-extra-test: runs-on: ubuntu-latest diff --git a/org_rw/dom.py b/org_rw/dom.py index 36493f6..cd8d63b 100644 --- a/org_rw/dom.py +++ b/org_rw/dom.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import List, Optional, Union class DrawerNode: @@ -95,7 +95,7 @@ class CodeBlock(BlockNode): def __init__(self, header, subtype, arguments): super().__init__() self.header = header - self.lines = None + self.lines: Optional[List] = None self.subtype = subtype self.arguments = arguments @@ -103,7 +103,7 @@ class CodeBlock(BlockNode): self.lines = lines def __repr__(self): - return "".format(len(self.lines)) + return "".format(len(self.lines or [])) DomNode = Union[DrawerNode, PropertyNode, @@ -116,4 +116,10 @@ DomNode = Union[DrawerNode, BlockNode, ] +ContainerDomNode = Union[DrawerNode, + ListGroupNode, + TableNode, + BlockNode, + ] + from .utils import get_raw_contents diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 318c4df..5bb205e 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -9,7 +9,7 @@ import re import sys from datetime import date, datetime, timedelta from enum import Enum -from typing import cast, Iterator, List, Optional, Tuple, Union +from typing import cast, Iterator, List, Literal, Optional, Tuple, Union from .types import HeadlineDict @@ -366,7 +366,7 @@ class Headline: tree: List[dom.DomNode] = [] current_node: Optional[dom.DomNode] = None - indentation_tree: List[dom.DomNode] = [] + indentation_tree: List[dom.ContainerDomNode] = [] contents: Optional[str] = None for line in sorted(everything, key=get_line): @@ -402,7 +402,7 @@ class Headline: elif isinstance(line, Text): tree_up = list(indentation_tree) while len(tree_up) > 0: - node = tree_up[-1] + node: dom.DomNode = tree_up[-1] if (isinstance(node, dom.BlockNode) or isinstance(node, dom.DrawerNode) ): @@ -508,6 +508,7 @@ class Headline: node = dom.TableSeparatorRow(orig=line) else: node = dom.TableRow(line.cells, orig=line) + current_node = cast(dom.ContainerDomNode, current_node) current_node.append(node) elif ( @@ -607,7 +608,7 @@ class Headline: return self.get_lists() def get_tables(self): - tables = [] + tables: List[List] = [] # TableRow[][] last_line = None for row in self.table_rows: @@ -666,6 +667,7 @@ class Headline: time_seg = content[len("CLOCK:") :].strip() + parsed: Union[None, OrgTime, TimeRange] = None if "--" in time_seg: # TODO: Consider duration start, end = time_seg.split("=")[0].split("--") @@ -1307,7 +1309,7 @@ class Link: return "[[{}]]".format(self.value) def _update_content(self): - new_contents = [] + new_contents: List[Union[str, LinkToken]] = [] new_contents.append(self._value) if self._description: new_contents.append(LinkToken(LinkTokenType.OPEN_DESCRIPTION)) @@ -1509,9 +1511,13 @@ TOKEN_TYPE_OPEN_LINK = 3 TOKEN_TYPE_CLOSE_LINK = 4 TOKEN_TYPE_OPEN_DESCRIPTION = 5 +TokenItems = Union[ + Tuple[int, Union[None, str, MarkerToken]], +] -def tokenize_contents(contents: str): - tokens = [] + +def tokenize_contents(contents: str) -> List[TokenItems]: + tokens: List[TokenItems] = [] last_char = None text: List[str] = [] @@ -1675,14 +1681,17 @@ def parse_content_block(raw_contents: Union[List[RawLine],str]): else: current_line = raw_contents[0].linenum - contents = [] + contents: List[Union[str, MarkerToken, LinkToken]] = [] # Use tokens to tag chunks of text with it's container type for (tok_type, tok_val) in tokens: if tok_type == TOKEN_TYPE_TEXT: + assert isinstance(tok_val, str) contents.append(tok_val) elif tok_type == TOKEN_TYPE_OPEN_MARKER: + assert isinstance(tok_val, str) contents.append(MarkerToken(False, MARKERS[tok_val])) elif tok_type == TOKEN_TYPE_CLOSE_MARKER: + assert isinstance(tok_val, str) contents.append(MarkerToken(True, MARKERS[tok_val])) elif tok_type == TOKEN_TYPE_OPEN_LINK: contents.append(LinkToken(LinkTokenType.OPEN_LINK)) @@ -2338,6 +2347,7 @@ def loads(s, environment=BASE_ENVIRONMENT, extra_cautious=True): context_start = i context_last_line = i elif context_start: + assert context_last_line is not None if i > (context_last_line + DEBUG_DIFF_CONTEXT): start = max(0, context_start - DEBUG_DIFF_CONTEXT) end = min(len(diff), context_last_line + DEBUG_DIFF_CONTEXT) From e4821f02cd88344568b45c303d8de5903c8d3fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Mon, 16 Oct 2023 23:16:41 +0200 Subject: [PATCH 08/55] Fix: run checks on the updated headline_hierarchy. --- org_rw/org_rw.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 5bb205e..2b344eb 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -2104,8 +2104,8 @@ class OrgDocReader: headline_hierarchy.append(headline) if all([hl is not None for hl in headline_hierarchy]): - if not ([ len(hl['orig'].group('stars')) for hl in self.headline_hierarchy ] - == list(range(1, len(self.headline_hierarchy) + 1))): + if not ([ len(hl['orig'].group('stars')) for hl in headline_hierarchy ] + == list(range(1, len(headline_hierarchy) + 1))): raise AssertionError('Error on Headline Hierarchy') else: raise AssertionError('None found on headline hierarchy') From 9d87d533f45ed65b1953ac884685e57a92b3704e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Mon, 16 Oct 2023 23:32:18 +0200 Subject: [PATCH 09/55] Add (failing) test --- tests/12-headlines-with-skip-levels.org | 22 ++++++++++++++++++++++ tests/test_org.py | 7 +++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/12-headlines-with-skip-levels.org diff --git a/tests/12-headlines-with-skip-levels.org b/tests/12-headlines-with-skip-levels.org new file mode 100644 index 0000000..17008be --- /dev/null +++ b/tests/12-headlines-with-skip-levels.org @@ -0,0 +1,22 @@ +#+TITLE: 12-Headlines with skip levels +#+DESCRIPTION: Simple org file to test Headlines with skip levels +#+TODO: TODO(t) PAUSED(p) | DONE(d) + +* Level 1 + :PROPERTIES: + :ID: 12-headlines-with-skip-levels + :CREATED: [2020-01-01 Wed 01:01] + :END: + +*** Level 3 + +*** Level 3-2 + +* Level 1-2 + +** Level 2 + +**** Level 4 + +*** Level3 + diff --git a/tests/test_org.py b/tests/test_org.py index 2f4200d..6981b76 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -757,6 +757,13 @@ class TestSerde(unittest.TestCase): self.assertEqual(children[3].children[0].content, ['2.1']) self.assertEqual(children[3].children[1].content, ['2.2']) + def test_mimic_write_file_12(self): + with open(os.path.join(DIR, "12-headlines-with-skip-levels.org")) as f: + orig = f.read() + doc = loads(orig) + + self.assertEqual(dumps(doc), orig) + def print_tree(tree, indentation=0, headline=None): for element in tree: From e26a2f04ac94c421add0dff2be75ed67cb5cb63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Mon, 16 Oct 2023 23:38:17 +0200 Subject: [PATCH 10/55] Fix typing of `headline_hierarchy`, remove incorrect assertion. --- org_rw/org_rw.py | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 2b344eb..7d01fcc 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -2043,7 +2043,7 @@ class OrgDocReader: def __init__(self): self.headlines: List[HeadlineDict] = [] self.keywords: List[Keyword] = [] - self.headline_hierarchy: List[HeadlineDict] = [] + self.headline_hierarchy: List[Optional[HeadlineDict]] = [] self.contents: List[RawLine] = [] self.delimiters: List[DelimiterLine] = [] self.list_items: List[ListItem] = [] @@ -2084,33 +2084,35 @@ class OrgDocReader: "table_rows": [], } - headline_hierarchy: List[Optional[HeadlineDict]] = list(self.headline_hierarchy) - - while (depth - 1) > len(headline_hierarchy): + while (depth - 1) > len(self.headline_hierarchy): # Introduce structural headlines - headline_hierarchy.append(None) - while depth <= len(headline_hierarchy): - headline_hierarchy.pop() + self.headline_hierarchy.append(None) + while depth <= len(self.headline_hierarchy): + self.headline_hierarchy.pop() if depth == 1: self.headlines.append(headline) else: - parent_idx = len(headline_hierarchy) - 1 - while headline_hierarchy[parent_idx] is None: + parent_idx = len(self.headline_hierarchy) - 1 + while self.headline_hierarchy[parent_idx] is None: parent_idx -= 1 - parent_headline = headline_hierarchy[parent_idx] + parent_headline = self.headline_hierarchy[parent_idx] assert parent_headline is not None parent_headline["children"].append(headline) - headline_hierarchy.append(headline) + self.headline_hierarchy.append(headline) - if all([hl is not None for hl in headline_hierarchy]): - if not ([ len(hl['orig'].group('stars')) for hl in headline_hierarchy ] - == list(range(1, len(headline_hierarchy) + 1))): + if all([hl is not None for hl in self.headline_hierarchy]): + if not ([ len(cast(HeadlineDict, hl)['orig'].group('stars')) for hl in self.headline_hierarchy ] + == list(range(1, len(self.headline_hierarchy) + 1))): raise AssertionError('Error on Headline Hierarchy') else: - raise AssertionError('None found on headline hierarchy') + # This might happen if headlines with more that 1 level deeper are found + pass - self.headline_hierarchy = cast(List[HeadlineDict], headline_hierarchy) + # We can safely assert this as all the `None`s are there to + # support the addition of a `HeadlineDict` at the correct + # depth but not more + assert self.headline_hierarchy[-1] is not None def add_list_item_line(self, linenum: int, match: re.Match) -> ListItem: li = ListItem( @@ -2134,6 +2136,7 @@ class OrgDocReader: if len(self.headline_hierarchy) == 0: self.list_items.append(li) else: + assert self.headline_hierarchy[-1] is not None self.headline_hierarchy[-1]["list_items"].append(li) return li @@ -2160,6 +2163,7 @@ class OrgDocReader: if len(self.headline_hierarchy) == 0: self.table_rows.append(row) else: + assert self.headline_hierarchy[-1] is not None self.headline_hierarchy[-1]["table_rows"].append(row) def add_keyword_line(self, linenum: int, match: re.Match): @@ -2174,6 +2178,7 @@ class OrgDocReader: if len(self.headline_hierarchy) == 0: self.keywords.append(kw) else: + assert self.headline_hierarchy[-1] is not None self.headline_hierarchy[-1]["keywords"].append(kw) def add_raw_line(self, linenum: int, line: str): @@ -2181,6 +2186,7 @@ class OrgDocReader: if len(self.headline_hierarchy) == 0: self.contents.append(raw) else: + assert self.headline_hierarchy[-1] is not None self.headline_hierarchy[-1]["contents"].append(raw) def add_begin_block_line(self, linenum: int, match: re.Match): @@ -2189,6 +2195,7 @@ class OrgDocReader: if len(self.headline_hierarchy) == 0: self.delimiters.append(line) else: + assert self.headline_hierarchy[-1] is not None self.headline_hierarchy[-1]["delimiters"].append(line) def add_end_block_line(self, linenum: int, match: re.Match): @@ -2197,6 +2204,7 @@ class OrgDocReader: if len(self.headline_hierarchy) == 0: self.delimiters.append(line) else: + assert self.headline_hierarchy[-1] is not None self.headline_hierarchy[-1]["delimiters"].append(line) def add_property_drawer_line(self, linenum: int, line: str, match: re.Match): @@ -2204,14 +2212,17 @@ class OrgDocReader: self.current_drawer = self.properties self.structural.append((linenum, line)) else: + assert self.headline_hierarchy[-1] is not None self.current_drawer = self.headline_hierarchy[-1]["properties"] self.headline_hierarchy[-1]["structural"].append((linenum, line)) def add_results_drawer_line(self, linenum: int, line: str, match: re.Match): + assert self.headline_hierarchy[-1] is not None self.current_drawer = self.headline_hierarchy[-1]["results"] self.headline_hierarchy[-1]["structural"].append((linenum, line)) def add_logbook_drawer_line(self, linenum: int, line: str, match: re.Match): + assert self.headline_hierarchy[-1] is not None self.current_drawer = self.headline_hierarchy[-1]["logbook"] self.headline_hierarchy[-1]["structural"].append((linenum, line)) @@ -2220,6 +2231,7 @@ class OrgDocReader: if len(self.headline_hierarchy) == 0: self.structural.append((linenum, line)) else: + assert self.headline_hierarchy[-1] is not None self.headline_hierarchy[-1]["structural"].append((linenum, line)) def add_node_properties_line(self, linenum: int, match: re.Match): From 985098e09177dfc14ccdfbdd9ecdec41b8b9b84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 4 Feb 2024 00:18:31 +0100 Subject: [PATCH 11/55] Find web links not marked as such when returning `doc.get_links()`. --- org_rw/org_rw.py | 12 +++++++++++- tests/03-links.org | 7 +++++++ tests/test_org.py | 40 +++++++++++++++++++++++++++++++++++----- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 7d01fcc..b1dff79 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -94,6 +94,8 @@ LIST_ITEM_RE = re.compile( r"(?P\s*)((?P[*\-+])|((?P\d|[a-zA-Z])(?P[.)]))) ((?P\s*)\[(?P[ Xx])\])?((?P\s*)(?P.*?)::)?(?P.*)" ) +IMPLICIT_LINK_RE = re.compile(r'(https?:[^<> ]*[a-zA-Z])') + # Org-Babel BEGIN_BLOCK_RE = re.compile(r"^\s*#\+BEGIN_(?P[^ ]+)(?P.*)$", re.I) END_BLOCK_RE = re.compile(r"^\s*#\+END_(?P[^ ]+)\s*$", re.I) @@ -206,6 +208,14 @@ def get_links_from_content(content): link_description.append(tok) else: link_value.append(tok) + elif isinstance(tok, str): + implicit_links = IMPLICIT_LINK_RE.findall(tok) + for link in implicit_links: + yield Link( + cast(str, link), + cast(str, link), + None + ) def text_to_dom(tokens, item): if tokens is None: @@ -1297,7 +1307,7 @@ class Line: class Link: - def __init__(self, value: str, description: Optional[str], origin: RangeInRaw): + def __init__(self, value: str, description: Optional[str], origin: Optional[RangeInRaw]): self._value = value self._description = description self._origin = origin diff --git a/tests/03-links.org b/tests/03-links.org index ad38d7a..7ab2d75 100644 --- a/tests/03-links.org +++ b/tests/03-links.org @@ -21,3 +21,10 @@ This is a [[https://codigoparallevar.com/4][[tricky web link]​]] followed up with some text. This is [[[https://codigoparallevar.com/5][another tricky web link]]] followed up with some text. + +* Implicit links + :PROPERTIES: + :ID: 03-markup-implicit-links + :CREATED: [2020-01-01 Wed 01:01] + :END: + This is an implicit web link: https://codigoparallevar.com/implicit. diff --git a/tests/test_org.py b/tests/test_org.py index 6981b76..8631fba 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -202,7 +202,7 @@ class TestSerde(unittest.TestCase): doc = load(f) links = list(doc.get_links()) - self.assertEqual(len(links), 7) + self.assertEqual(len(links), 8) self.assertEqual(links[0].value, "https://codigoparallevar.com/1") self.assertEqual(links[0].description, "web link") @@ -224,6 +224,9 @@ class TestSerde(unittest.TestCase): self.assertEqual(links[6].value, "https://codigoparallevar.com/5") self.assertEqual(links[6].description, "another tricky web link") + self.assertEqual(links[7].value, "https://codigoparallevar.com/implicit") + self.assertEqual(links[7].description, "https://codigoparallevar.com/implicit") + ex = Doc( props=[ ("TITLE", "03-Links"), @@ -290,8 +293,20 @@ class TestSerde(unittest.TestCase): "] followed up with some text.\n", ), ], - ) - ), + ), + HL( + "Implicit links", + props=[ + ("ID", "03-markup-implicit-links"), + ("CREATED", DT(2020, 1, 1, 1, 1)), + ], + content=[ + SPAN( + " This is an implicit web link: https://codigoparallevar.com/implicit.\n", + ), + ], + ), + ) ) ex.assert_matches(self, doc) @@ -301,7 +316,7 @@ class TestSerde(unittest.TestCase): doc = load(f) links = list(doc.get_links()) - self.assertEqual(len(links), 7) + self.assertEqual(len(links), 8) self.assertEqual(links[0].value, "https://codigoparallevar.com/1") self.assertEqual(links[0].description, "web link") links[0].value = "https://codigoparallevar.com/1-updated" @@ -337,6 +352,9 @@ class TestSerde(unittest.TestCase): links[6].value = "https://codigoparallevar.com/5-updated" links[6].description = "another tricky web link #5 with update" + self.assertEqual(links[7].value, "https://codigoparallevar.com/implicit") + self.assertEqual(links[7].description, "https://codigoparallevar.com/implicit") + ex = Doc( props=[ ("TITLE", "03-Links"), @@ -416,7 +434,19 @@ class TestSerde(unittest.TestCase): "] followed up with some text.\n", ), ], - ) + ), + HL( + "Implicit links", + props=[ + ("ID", "03-markup-implicit-links"), + ("CREATED", DT(2020, 1, 1, 1, 1)), + ], + content=[ + SPAN( + " This is an implicit web link: https://codigoparallevar.com/implicit.\n", + ), + ], + ), ), ) From 4fd29819eacf66e801a662e6202d4cffcff217c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Wed, 21 Feb 2024 23:00:59 +0100 Subject: [PATCH 12/55] Fix implicit link parsing. --- org_rw/org_rw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index b1dff79..3217430 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -94,7 +94,7 @@ LIST_ITEM_RE = re.compile( r"(?P\s*)((?P[*\-+])|((?P\d|[a-zA-Z])(?P[.)]))) ((?P\s*)\[(?P[ Xx])\])?((?P\s*)(?P.*?)::)?(?P.*)" ) -IMPLICIT_LINK_RE = re.compile(r'(https?:[^<> ]*[a-zA-Z])') +IMPLICIT_LINK_RE = re.compile(r'(https?:[^<> ]*[a-zA-Z0-9])') # Org-Babel BEGIN_BLOCK_RE = re.compile(r"^\s*#\+BEGIN_(?P[^ ]+)(?P.*)$", re.I) From 9e994ba3230c629aac50026b5b5fdb4a6bc96d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Wed, 21 Feb 2024 23:00:59 +0100 Subject: [PATCH 13/55] Accept numbers as end of implicit link. --- org_rw/org_rw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index b1dff79..3217430 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -94,7 +94,7 @@ LIST_ITEM_RE = re.compile( r"(?P\s*)((?P[*\-+])|((?P\d|[a-zA-Z])(?P[.)]))) ((?P\s*)\[(?P[ Xx])\])?((?P\s*)(?P.*?)::)?(?P.*)" ) -IMPLICIT_LINK_RE = re.compile(r'(https?:[^<> ]*[a-zA-Z])') +IMPLICIT_LINK_RE = re.compile(r'(https?:[^<> ]*[a-zA-Z0-9])') # Org-Babel BEGIN_BLOCK_RE = re.compile(r"^\s*#\+BEGIN_(?P[^ ]+)(?P.*)$", re.I) From 423d6f98420f01faf856329d7ab8e690c0aba2fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Fri, 22 Mar 2024 01:54:46 +0100 Subject: [PATCH 14/55] Fix multiline specifications of TODO properties. --- org_rw/org_rw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 3217430..c0a1244 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1871,7 +1871,7 @@ class OrgDoc: for keyword in keywords: if keyword.key in ("TODO", "SEQ_TODO"): - todo_kws, done_kws = re.sub(r"\(.\)", "", keyword.value).split("|", 1) + todo_kws, done_kws = re.sub(r"\([^)]+\)", "", keyword.value).split("|", 1) self.todo_keywords = re.sub(r"\s{2,}", " ", todo_kws.strip()).split() self.done_keywords = re.sub(r"\s{2,}", " ", done_kws.strip()).split() From a56ac018a87347c7e2bce8a77a39374670b27530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Wed, 21 Feb 2024 23:00:59 +0100 Subject: [PATCH 15/55] Prepare for PyPI pushising, bumb version. --- .gitignore | 3 +++ scripts/upload-to-pip.sh | 2 ++ setup.py | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5c8ee49..2fafd0e 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,6 @@ dmypy.json # Cython debug symbols cython_debug/ + +# Files for PyPI publishing +README.md diff --git a/scripts/upload-to-pip.sh b/scripts/upload-to-pip.sh index c364cbe..b5c55e4 100644 --- a/scripts/upload-to-pip.sh +++ b/scripts/upload-to-pip.sh @@ -5,6 +5,8 @@ set -eu cd "`dirname $0`" cd .. +pandoc README.org -o README.md # PyPI doesn't accept Org files + python setup.py sdist twine upload --verbose dist/* diff --git a/setup.py b/setup.py index 4ef44b3..1295538 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name="org-rw", - version="0.0.1.dev1", + version="0.0.2", description="Library to de/serialize org-files and manipulate them.", author="kenkeiras", author_email="kenkeiras@codigoparallevar.com", From c5cc14f65c03173bfb24951db709100e79286ee7 Mon Sep 17 00:00:00 2001 From: Lyz Date: Fri, 19 Jul 2024 21:36:00 +0200 Subject: [PATCH 16/55] feat(Timestamp): add the from_datetime method To update the current Timestamp instance based on a datetime or date object. I've also included a set_datetime method to OrgTime feat: add activate and deactivate methods to TimeRange and OrgTime I need it in a program I'm making refactor: Create the Time type hint I had to move the parse_time and parse_org_time_range below OrgTime because it used the Time type hint and the Time type hint needed the other two style: reformat the code following black style: Add some type hints and docstrings style: remove unused imports tests: Correct some mypy errors --- org_rw/org_rw.py | 653 +++++++++++++++++++++++++++++++++------------- org_rw/types.py | 1 + tests/test_org.py | 163 +++++++----- 3 files changed, 581 insertions(+), 236 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 3217430..b4067ba 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1,7 +1,7 @@ from __future__ import annotations - +from typing import Optional +from datetime import timedelta import collections -from ctypes import ArgumentError import difflib import logging import os @@ -9,12 +9,13 @@ import re import sys from datetime import date, datetime, timedelta from enum import Enum -from typing import cast, Iterator, List, Literal, Optional, Tuple, Union +from typing import cast, Iterator, List, Optional, Tuple, Union from .types import HeadlineDict from . import dom + DEBUG_DIFF_CONTEXT = 10 BASE_ENVIRONMENT = { @@ -94,16 +95,23 @@ LIST_ITEM_RE = re.compile( r"(?P\s*)((?P[*\-+])|((?P\d|[a-zA-Z])(?P[.)]))) ((?P\s*)\[(?P[ Xx])\])?((?P\s*)(?P.*?)::)?(?P.*)" ) -IMPLICIT_LINK_RE = re.compile(r'(https?:[^<> ]*[a-zA-Z0-9])') +IMPLICIT_LINK_RE = re.compile(r"(https?:[^<> ]*[a-zA-Z0-9])") # Org-Babel BEGIN_BLOCK_RE = re.compile(r"^\s*#\+BEGIN_(?P[^ ]+)(?P.*)$", re.I) END_BLOCK_RE = re.compile(r"^\s*#\+END_(?P[^ ]+)\s*$", re.I) RESULTS_DRAWER_RE = re.compile(r"^\s*:results:\s*$", re.I) -CodeSnippet = collections.namedtuple("CodeSnippet", ("name", "content", "result", "arguments")) +CodeSnippet = collections.namedtuple( + "CodeSnippet", ("name", "content", "result", "arguments") +) # Groupings -NON_FINISHED_GROUPS = (type(None), dom.ListGroupNode, dom.ResultsDrawerNode, dom.PropertyDrawerNode) +NON_FINISHED_GROUPS = ( + type(None), + dom.ListGroupNode, + dom.ResultsDrawerNode, + dom.PropertyDrawerNode, +) FREE_GROUPS = (dom.CodeBlock,) @@ -112,6 +120,7 @@ class NonReproducibleDocument(Exception): Exception thrown when a document would be saved as different contents from what it's loaded from. """ + pass @@ -164,20 +173,19 @@ def unescape_block_lines(block: str) -> str: Remove leading ',' from block_lines if they escape `*` characters. """ i = 0 - lines = block.split('\n') + lines = block.split("\n") while i < len(lines): line = lines[i] - if (line.lstrip(' ').startswith(',') - and line.lstrip(' ,').startswith('*') - ): + if line.lstrip(" ").startswith(",") and line.lstrip(" ,").startswith("*"): # Remove leading ',' - lead_pos = line.index(',') - line = line[:lead_pos] + line[lead_pos + 1:] + lead_pos = line.index(",") + line = line[:lead_pos] + line[lead_pos + 1 :] lines[i] = line i += 1 - return '\n'.join(lines) + return "\n".join(lines) + def get_links_from_content(content): in_link = False @@ -211,11 +219,8 @@ def get_links_from_content(content): elif isinstance(tok, str): implicit_links = IMPLICIT_LINK_RE.findall(tok) for link in implicit_links: - yield Link( - cast(str, link), - cast(str, link), - None - ) + yield Link(cast(str, link), cast(str, link), None) + def text_to_dom(tokens, item): if tokens is None: @@ -237,11 +242,13 @@ def text_to_dom(tokens, item): in_description = True elif tok.tok_type == LinkTokenType.CLOSE: rng = RangeInRaw(item, open_link_token, tok) - contents.append(Link( - "".join(link_value), - "".join(link_description) if in_description else None, - rng, - )) + contents.append( + Link( + "".join(link_value), + "".join(link_description) if in_description else None, + rng, + ) + ) in_link = False in_description = False link_value = [] @@ -256,6 +263,7 @@ def text_to_dom(tokens, item): return contents + def get_line(item): if isinstance(item, Text): return item.linenum @@ -291,8 +299,8 @@ class Headline: list_items, table_rows, parent, - is_todo, - is_done, + is_todo: bool, + is_done: bool, spacing, ): self.start_line = start_line @@ -303,9 +311,7 @@ class Headline: self.priority_start = priority_start self.priority = priority self.title_start = title_start - self.title = parse_content_block( - [RawLine(linenum=start_line, line=title)] - ) + self.title = parse_content_block([RawLine(linenum=start_line, line=title)]) self.state = state self.tags_start = tags_start self.shallow_tags = tags @@ -318,9 +324,9 @@ class Headline: self.parent = parent self.is_todo = is_todo self.is_done = is_done - self.scheduled = None - self.deadline = None - self.closed = None + self.scheduled: Time = None + self.deadline: Time = None + self.closed: Time = None self.spacing = spacing # Read planning line @@ -362,7 +368,6 @@ class Headline: par = par.parent return par - def as_dom(self): everything = ( self.keywords @@ -400,7 +405,7 @@ class Headline: tree.append(current_node) current_node = None else: - pass # Ignore + pass # Ignore elif isinstance(line, Property): if type(current_node) in NON_FINISHED_GROUPS: @@ -413,22 +418,24 @@ class Headline: tree_up = list(indentation_tree) while len(tree_up) > 0: node: dom.DomNode = tree_up[-1] - if (isinstance(node, dom.BlockNode) - or isinstance(node, dom.DrawerNode) + if isinstance(node, dom.BlockNode) or isinstance( + node, dom.DrawerNode ): node.append(dom.Text(line)) current_node = node contents = None break - elif ((not isinstance(node, dom.TableNode)) and - (type(node) not in NON_FINISHED_GROUPS) + elif (not isinstance(node, dom.TableNode)) and ( + type(node) not in NON_FINISHED_GROUPS ): - raise NotImplementedError('Not implemented node type: {} (headline_id={}, line={}, doc={})'.format( - node, - self.id, - line.linenum, - self.doc.path, - )) + raise NotImplementedError( + "Not implemented node type: {} (headline_id={}, line={}, doc={})".format( + node, + self.id, + line.linenum, + self.doc.path, + ) + ) else: tree_up.pop(-1) else: @@ -438,7 +445,8 @@ class Headline: indentation_tree = tree_up elif isinstance(line, ListItem): - if (current_node is None + if ( + current_node is None or isinstance(current_node, dom.TableNode) or isinstance(current_node, dom.BlockNode) or isinstance(current_node, dom.DrawerNode) @@ -452,7 +460,14 @@ class Headline: indentation_tree.append(current_node) if not isinstance(current_node, dom.ListGroupNode): if not isinstance(current_node, dom.ListGroupNode): - raise Exception("Expected a {}, found: {} on line {} on {}".format(dom.ListGroupNode, current_node, line.linenum, self.doc.path)) + raise Exception( + "Expected a {}, found: {} on line {} on {}".format( + dom.ListGroupNode, + current_node, + line.linenum, + self.doc.path, + ) + ) # This can happen. Frequently inside a LogDrawer if len(indentation_tree) > 0 and ( @@ -478,10 +493,9 @@ class Headline: if isinstance(c, dom.ListItem) ] - if (len(list_children) == 0): + if len(list_children) == 0: break - if ((len(list_children[-1].orig.indentation) - <= len(line.indentation))): + if len(list_children[-1].orig.indentation) <= len(line.indentation): # No more breaking out of lists, it's indentation # is less than ours break @@ -494,7 +508,11 @@ class Headline: else: current_node = indentation_tree[-1] - node = dom.ListItem(text_to_dom(line.tag, line), text_to_dom(line.content, line), orig=line) + node = dom.ListItem( + text_to_dom(line.tag, line), + text_to_dom(line.content, line), + orig=line, + ) current_node.append(node) elif isinstance(line, TableRow): @@ -511,10 +529,18 @@ class Headline: list_node.append(current_node) indentation_tree.append(current_node) else: - logging.debug("Expected a {}, found: {} on line {}".format(dom.TableNode, current_node, line.linenum)) + logging.debug( + "Expected a {}, found: {} on line {}".format( + dom.TableNode, current_node, line.linenum + ) + ) # This can happen. Frequently inside a LogDrawer - if len(line.cells) > 0 and len(line.cells[0]) > 0 and line.cells[0][0] == '-': + if ( + len(line.cells) > 0 + and len(line.cells[0]) > 0 + and line.cells[0][0] == "-" + ): node = dom.TableSeparatorRow(orig=line) else: node = dom.TableRow(line.cells, orig=line) @@ -526,7 +552,9 @@ class Headline: and line.delimiter_type == DelimiterLineType.BEGIN_BLOCK ): assert type(current_node) in NON_FINISHED_GROUPS - current_node = dom.CodeBlock(line, line.type_data.subtype, line.arguments) + current_node = dom.CodeBlock( + line, line.type_data.subtype, line.arguments + ) elif isinstance(line, Keyword): logging.warning("Keywords not implemented on `as_dom()`") @@ -560,7 +588,7 @@ class Headline: indentation_tree = [current_node] elif content.strip().upper() == ":END:": if current_node is None and len(indentation_tree) == 0: - logging.error('Finished node (:END:) with no known starter') + logging.error("Finished node (:END:) with no known starter") else: tree_up = list(indentation_tree) while len(tree_up) > 0: @@ -573,7 +601,11 @@ class Headline: else: tree_up.pop(-1) else: - raise Exception('Unexpected node ({}) on headline (id={}), line {}'.format(current_node, self.id, linenum)) + raise Exception( + "Unexpected node ({}) on headline (id={}), line {}".format( + current_node, self.id, linenum + ) + ) current_node = None elif content.strip().upper() == ":RESULTS:": assert current_node is None @@ -598,19 +630,22 @@ class Headline: lists.append([li]) else: num_lines = li.linenum - (last_line + 1) - lines_between = ''.join(['\n' + l - for l in self.get_lines_between(last_line + 1, li.linenum)] - ) + lines_between = "".join( + [ + "\n" + l + for l in self.get_lines_between(last_line + 1, li.linenum) + ] + ) # Only empty lines - if ((num_lines == lines_between.count('\n')) - and (len(lines_between.strip()) == 0) + if (num_lines == lines_between.count("\n")) and ( + len(lines_between.strip()) == 0 ): lists[-1].append(li) else: lists.append([li]) - last_line = li.linenum + sum(c.count('\n') for c in li.content) + last_line = li.linenum + sum(c.count("\n") for c in li.content) return lists # @DEPRECATED: use `get_lists` @@ -677,7 +712,7 @@ class Headline: time_seg = content[len("CLOCK:") :].strip() - parsed: Union[None, OrgTime, TimeRange] = None + parsed: Time = None if "--" in time_seg: # TODO: Consider duration start, end = time_seg.split("=")[0].split("--") @@ -755,7 +790,7 @@ class Headline: for lst in self.get_lists(): for item in lst: if item.tag: - yield from get_links_from_content(item.tag) + yield from get_links_from_content(item.tag) yield from get_links_from_content(item.content) def get_lines_between(self, start, end): @@ -777,7 +812,7 @@ class Headline: if linenum == line.linenum: return line - for (s_lnum, struc) in self.structural: + for s_lnum, struc in self.structural: if linenum == s_lnum: return ("structural", struc) @@ -803,7 +838,7 @@ class Headline: ) def get_structural_end_after(self, linenum): - for (s_lnum, struc) in self.structural: + for s_lnum, struc in self.structural: if s_lnum > linenum and struc.strip().upper() == ":END:": return (s_lnum, struc) @@ -814,11 +849,17 @@ class Headline: arguments = None for delimiter in self.delimiters: - if delimiter.delimiter_type == DelimiterLineType.BEGIN_BLOCK and delimiter.type_data.subtype.lower() == "src": + if ( + delimiter.delimiter_type == DelimiterLineType.BEGIN_BLOCK + and delimiter.type_data.subtype.lower() == "src" + ): line_start = delimiter.linenum inside_code = True arguments = delimiter.arguments - elif delimiter.delimiter_type == DelimiterLineType.END_BLOCK and delimiter.type_data.subtype.lower() == "src": + elif ( + delimiter.delimiter_type == DelimiterLineType.END_BLOCK + and delimiter.type_data.subtype.lower() == "src" + ): inside_code = False start, end = line_start, delimiter.linenum @@ -889,7 +930,11 @@ class Headline: content = section["content"] code_result = section.get("result", None) arguments = section.get("arguments", None) - results.append(CodeSnippet(name=name, content=content, result=code_result, arguments=arguments)) + results.append( + CodeSnippet( + name=name, content=content, result=code_result, arguments=arguments + ) + ) return results @@ -931,13 +976,20 @@ Property = collections.namedtuple( "Property", ("linenum", "match", "key", "value", "options") ) + class ListItem: - def __init__(self, - linenum, match, + def __init__( + self, + linenum, + match, indentation, - bullet, counter, counter_sep, - checkbox_indentation, checkbox_value, - tag_indentation, tag, + bullet, + counter, + counter_sep, + checkbox_indentation, + checkbox_value, + tag_indentation, + tag, content, ): self.linenum = linenum @@ -954,10 +1006,11 @@ class ListItem: @property def text_start_pos(self): - return len(self.indentation) + 1 # Indentation + bullet + return len(self.indentation) + 1 # Indentation + bullet def append_line(self, line): - self.content += parse_content_block('\n' + line).contents + self.content += parse_content_block("\n" + line).contents + TableRow = collections.namedtuple( "TableRow", @@ -970,10 +1023,34 @@ TableRow = collections.namedtuple( ), ) + # @TODO How are [YYYY-MM-DD HH:mm--HH:mm] and ([... HH:mm]--[... HH:mm]) differentiated ? # @TODO Consider recurrence annotations class Timestamp: - def __init__(self, active, year, month, day, dow, hour, minute, repetition=None): + def __init__( + self, + active: bool, + year: int, + month: int, + day: int, + dow: Optional[str], + hour: Optional[int], + minute: Optional[int], + repetition: Optional[str] = None, + ): + """ + Initializes a Timestamp instance. + + Args: + active (bool): Whether the timestamp is active. + year (int): The year of the timestamp. + month (int): The month of the timestamp. + day (int): The day of the timestamp. + dow (Optional[str]): The day of the week, if any. + hour (Optional[int]): The hour of the timestamp, if any. + minute (Optional[int]): The minute of the timestamp, if any. + repetition (Optional[str]): The repetition pattern, if any. + """ self.active = active self._year = year self._month = month @@ -984,12 +1061,51 @@ class Timestamp: self.repetition = repetition def to_datetime(self) -> datetime: + """ + Converts the Timestamp to a datetime object. + + Returns: + datetime: The corresponding datetime object. + """ if self.hour is not None: return datetime(self.year, self.month, self.day, self.hour, self.minute) else: return datetime(self.year, self.month, self.day, 0, 0) - def __add__(self, delta: timedelta): + def from_datetime(self, dt: Union[datetime, date]) -> None: + """ + Updates the current Timestamp instance based on a datetime or date object. + + Args: + dt (Union[datetime, date]): The datetime or date object to use for updating the instance. + """ + if isinstance(dt, datetime): + self._year = dt.year + self._month = dt.month + self._day = dt.day + self.hour = dt.hour + self.minute = dt.minute + elif isinstance(dt, date): + self._year = dt.year + self._month = dt.month + self._day = dt.day + self.hour = None + self.minute = None + else: + raise TypeError("Expected datetime or date object") + + self.dow = None # Day of the week can be set to None + + def __add__(self, delta: timedelta) -> "Timestamp": + """ + Adds a timedelta to the Timestamp. + + Args: + delta (timedelta): The time difference to add. + + Returns: + Timestamp: The resulting Timestamp instance. + """ as_dt = self.to_datetime() to_dt = as_dt + delta @@ -1000,64 +1116,102 @@ class Timestamp: day=to_dt.day, dow=None, hour=to_dt.hour if self.hour is not None or to_dt.hour != 0 else None, - minute=to_dt.minute - if self.minute is not None or to_dt.minute != 0 - else None, + minute=( + to_dt.minute if self.minute is not None or to_dt.minute != 0 else None + ), repetition=self.repetition, ) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + """ + Checks if two Timestamp instances are equal. + + Args: + other (object): The other object to compare with. + + Returns: + bool: True if the instances are equal, False otherwise. + """ if not isinstance(other, Timestamp): return False return ( - (self.active == other.active) - and (self.year == other.year) - and (self.month == other.month) - and (self.day == other.day) - and (self.dow == other.dow) - and (self.hour == other.hour) - and (self.minute == other.minute) - and (self.repetition == other.repetition) + self.active == other.active + and self.year == other.year + and self.month == other.month + and self.day == other.day + and self.dow == other.dow + and self.hour == other.hour + and self.minute == other.minute + and self.repetition == other.repetition ) - def __lt__(self, other): + def __lt__(self, other: object) -> bool: + """ + Checks if the Timestamp is less than another Timestamp. + + Args: + other (object): The other object to compare with. + + Returns: + bool: True if this Timestamp is less than the other, False otherwise. + """ if not isinstance(other, Timestamp): return False return self.to_datetime() < other.to_datetime() - def __gt__(self, other): + def __gt__(self, other: object) -> bool: + """ + Checks if the Timestamp is greater than another Timestamp. + + Args: + other (object): The other object to compare with. + + Returns: + bool: True if this Timestamp is greater than the other, False otherwise. + """ if not isinstance(other, Timestamp): return False return self.to_datetime() > other.to_datetime() - def __repr__(self): + def __repr__(self) -> str: + """ + Returns a string representation of the Timestamp. + + Returns: + str: The string representation of the Timestamp. + """ return timestamp_to_string(self) - # Properties whose modification changes the Day-Of-Week @property - def year(self): + def year(self) -> int: + """Returns the year of the timestamp.""" return self._year @year.setter - def year(self, value): + def year(self, value: int) -> None: + """Sets the year of the timestamp and resets the day of the week.""" self._year = value self.dow = None @property - def month(self): + def month(self) -> int: + """Returns the month of the timestamp.""" return self._month @month.setter - def month(self, value): + def month(self, value: int) -> None: + """Sets the month of the timestamp and resets the day of the week.""" self._month = value self.dow = None @property - def day(self): + def day(self) -> int: + """Returns the day of the timestamp.""" return self._day @day.setter - def day(self, value): + def day(self, value: int) -> None: + """Sets the day of the timestamp and resets the day of the week.""" self._day = value self.dow = None @@ -1067,9 +1221,7 @@ class DelimiterLineType(Enum): END_BLOCK = 2 -BlockDelimiterTypeData = collections.namedtuple( - "BlockDelimiterTypeData", ("subtype") -) +BlockDelimiterTypeData = collections.namedtuple("BlockDelimiterTypeData", ("subtype")) DelimiterLine = collections.namedtuple( "DelimiterLine", ("linenum", "line", "delimiter_type", "type_data", "arguments") @@ -1119,90 +1271,153 @@ def token_from_type(tok_type): class TimeRange: - def __init__(self, start_time: OrgTime, end_time: OrgTime): - assert start_time is not None - assert end_time is not None + """Represents a range of time with a start and end time. + + Attributes: + start_time (OrgTime): The start time of the range. + end_time (OrgTime): The end time of the range. + """ + + def __init__(self, start_time: OrgTime, end_time: OrgTime) -> None: + """Initializes a TimeRange with a start time and an end time. + + Args: + start_time (OrgTime): The start time of the range. + end_time (OrgTime): The end time of the range. + + Raises: + AssertionError: If start_time or end_time is None. + """ + if start_time is None or end_time is None: + raise ValueError("start_time and end_time must not be None.") self.start_time = start_time self.end_time = end_time def to_raw(self) -> str: + """Converts the TimeRange to its raw string representation. + + Returns: + str: The raw string representation of the TimeRange. + """ return timerange_to_string(self) @property def duration(self) -> timedelta: + """Calculates the duration of the TimeRange. + + Returns: + timedelta: The duration between start_time and end_time. + """ delta = self.end - self.start return delta @property def start(self) -> datetime: + """Gets the start time as a datetime object. + + Returns: + datetime: The start time of the TimeRange. + """ return self.start_time.time.to_datetime() @property def end(self) -> datetime: + """Gets the end time as a datetime object. + + Returns: + datetime: The end time of the TimeRange. + """ return self.end_time.time.to_datetime() + def activate(self) -> None: + """ + Sets the active state for the times. + """ + self.start_time.active = True + self.end_time.active = True -def parse_time(value: str) -> Union[None, TimeRange, OrgTime]: - if (value.count(">--<") == 1) or (value.count("]--[") == 1): - # Time ranges with two different dates - # @TODO properly consider "=> DURATION" section - start, end = value.split("=")[0].split("--") - as_time_range = parse_org_time_range(start, end) - if as_time_range is None: - return None - - if (as_time_range.start_time is not None) and ( - as_time_range.end_time is not None - ): - return as_time_range - else: - raise Exception("Unknown time range format: {}".format(value)) - elif as_time := OrgTime.parse(value): - return as_time - else: - return None - - -def parse_org_time_range(start, end) -> Optional[TimeRange]: - start_time = OrgTime.parse(start) - end_time = OrgTime.parse(end) - - if start_time is None or end_time is None: - return None - return TimeRange(start_time, end_time) + def deactivate(self) -> None: + """ + Sets the inactive state for the times. + """ + self.start_time.active = False + self.end_time.active = False class OrgTime: - def __init__(self, ts: Timestamp, end_time: Optional[Timestamp] = None): - assert ts is not None + """Represents a point in time with optional end time and repetition. + + Attributes: + time (Timestamp): The start time of the OrgTime instance. + end_time (Optional[Timestamp]): The end time of the OrgTime instance, if any. + """ + + def __init__(self, ts: Timestamp, end_time: Optional[Timestamp] = None) -> None: + """Initializes an OrgTime with a start time and an optional end time. + + Args: + ts (Timestamp): The start time of the OrgTime instance. + end_time (Optional[Timestamp], optional): The end time of the OrgTime instance. Defaults to None. + + Raises: + ValueError: If ts is None. + """ + if ts is None: + raise ValueError("Timestamp (ts) must not be None.") self.time = ts self.end_time = end_time @property - def repetition(self): + def repetition(self) -> Optional[str]: + """Gets the repetition information from the start time. + + Returns: + Optional[str]: The repetition information, or None if not present. + """ return self.time.repetition @property - def duration(self): + def duration(self) -> timedelta: + """Calculates the duration between the start and end times. + + Returns: + timedelta: The duration between the start and end times. If no end time is present, returns zero timedelta. + """ if self.end_time is None: return timedelta() # No duration - else: - return self.end_time.to_datetime() - self.time.to_datetime() + return self.end_time.to_datetime() - self.time.to_datetime() - def to_raw(self): + def to_raw(self) -> str: + """Converts the OrgTime to its raw string representation. + + Returns: + str: The raw string representation of the OrgTime. + """ return timestamp_to_string(self.time, self.end_time) - def __repr__(self): + def __repr__(self) -> str: + """Provides a string representation of the OrgTime instance. + + Returns: + str: The string representation of the OrgTime. + """ return f"OrgTime({self.to_raw()})" @classmethod - def parse(self, value: str) -> Optional[OrgTime]: + def parse(cls, value: str) -> Optional["OrgTime"]: + """Parses a string into an OrgTime object. + + Args: + value (str): The string representation of the OrgTime. + + Returns: + Optional[OrgTime]: The parsed OrgTime instance, or None if parsing fails. + """ if m := ACTIVE_TIME_STAMP_RE.match(value): active = True elif m := INACTIVE_TIME_STAMP_RE.match(value): active = False else: - # raise ArgumentError("Cannot parse `{}` as OrgTime".format(value)) return None repetition = None @@ -1210,7 +1425,7 @@ class OrgTime: repetition = m.group("repetition").strip() if m.group("end_hour"): - return OrgTime( + return cls( Timestamp( active, int(m.group("year")), @@ -1232,7 +1447,7 @@ class OrgTime: ), ) - return OrgTime( + return cls( Timestamp( active, int(m.group("year")), @@ -1245,6 +1460,29 @@ class OrgTime: ) ) + def activate(self) -> None: + """ + Sets the active state for the timestamp. + """ + self.time.active = True + + def deactivate(self) -> None: + """ + Sets the inactive state for the timestamp. + """ + self.time.active = False + + def set_datetime(self, dt: datetime) -> None: + """ + Updates the timestamp to use the given datetime. + + Args: + dt (datetime): The datetime to update the timestamp with. + """ + self.time = Timestamp.from_datetime(dt) + if self.end_time: + self.end_time = Timestamp.from_datetime(dt) + def time_from_str(s: str) -> Optional[OrgTime]: return OrgTime.parse(s) @@ -1284,6 +1522,39 @@ def timestamp_to_string(ts: Timestamp, end_time: Optional[Timestamp] = None) -> return "[{}]".format(base) +Time = Union[None, TimeRange, OrgTime] + + +def parse_time(value: str) -> Time: + if (value.count(">--<") == 1) or (value.count("]--[") == 1): + # Time ranges with two different dates + # @TODO properly consider "=> DURATION" section + start, end = value.split("=")[0].split("--") + as_time_range = parse_org_time_range(start, end) + if as_time_range is None: + return None + + if (as_time_range.start_time is not None) and ( + as_time_range.end_time is not None + ): + return as_time_range + else: + raise Exception("Unknown time range format: {}".format(value)) + elif as_time := OrgTime.parse(value): + return as_time + else: + return None + + +def parse_org_time_range(start, end) -> Optional[TimeRange]: + start_time = OrgTime.parse(start) + end_time = OrgTime.parse(end) + + if start_time is None or end_time is None: + return None + return TimeRange(start_time, end_time) + + def get_raw(doc): if isinstance(doc, str): return doc @@ -1307,7 +1578,9 @@ class Line: class Link: - def __init__(self, value: str, description: Optional[str], origin: Optional[RangeInRaw]): + def __init__( + self, value: str, description: Optional[str], origin: Optional[RangeInRaw] + ): self._value = value self._description = description self._origin = origin @@ -1324,7 +1597,8 @@ class Link: if self._description: new_contents.append(LinkToken(LinkTokenType.OPEN_DESCRIPTION)) new_contents.append(self._description) - self._origin.update_range(new_contents) + if self._origin is not None: + self._origin.update_range(new_contents) @property def value(self): @@ -1359,6 +1633,7 @@ class Text: def get_raw(self): return token_list_to_raw(self.contents) + def token_list_to_plaintext(tok_list) -> str: contents = [] in_link = False @@ -1383,7 +1658,7 @@ def token_list_to_plaintext(tok_list) -> str: if not in_description: # This might happen when link doesn't have a separate description link_description = link_url - contents.append(''.join(link_description)) + contents.append("".join(link_description)) in_link = False in_description = False @@ -1394,6 +1669,7 @@ def token_list_to_plaintext(tok_list) -> str: return "".join(contents) + def token_list_to_raw(tok_list): contents = [] for chunk in tok_list: @@ -1521,13 +1797,11 @@ TOKEN_TYPE_OPEN_LINK = 3 TOKEN_TYPE_CLOSE_LINK = 4 TOKEN_TYPE_OPEN_DESCRIPTION = 5 -TokenItems = Union[ - Tuple[int, Union[None, str, MarkerToken]], -] +TokenItems = Union[Tuple[int, Union[None, str, MarkerToken]],] def tokenize_contents(contents: str) -> List[TokenItems]: - tokens: List[TokenItems] = [] + tokens: List[TokenItems] = [] last_char = None text: List[str] = [] @@ -1676,7 +1950,7 @@ def parse_contents(raw_contents: List[RawLine]): return [parse_content_block(block) for block in blocks] -def parse_content_block(raw_contents: Union[List[RawLine],str]): +def parse_content_block(raw_contents: Union[List[RawLine], str]): contents_buff = [] if isinstance(raw_contents, str): contents_buff.append(raw_contents) @@ -1693,7 +1967,7 @@ def parse_content_block(raw_contents: Union[List[RawLine],str]): contents: List[Union[str, MarkerToken, LinkToken]] = [] # Use tokens to tag chunks of text with it's container type - for (tok_type, tok_val) in tokens: + for tok_type, tok_val in tokens: if tok_type == TOKEN_TYPE_TEXT: assert isinstance(tok_val, str) contents.append(tok_val) @@ -1720,17 +1994,21 @@ def dump_contents(raw): elif isinstance(raw, ListItem): bullet = raw.bullet if raw.bullet else raw.counter + raw.counter_sep content_full = token_list_to_raw(raw.content) - content_lines = content_full.split('\n') - content = '\n'.join(content_lines) + content_lines = content_full.split("\n") + content = "\n".join(content_lines) checkbox = f"[{raw.checkbox_value}]" if raw.checkbox_value else "" - tag = f"{raw.tag_indentation}{token_list_to_raw(raw.tag or '')}::" if raw.tag or raw.tag_indentation else "" + tag = ( + f"{raw.tag_indentation}{token_list_to_raw(raw.tag or '')}::" + if raw.tag or raw.tag_indentation + else "" + ) return ( raw.linenum, f"{raw.indentation}{bullet} {checkbox}{tag}{content}", ) elif isinstance(raw, TableRow): - closed = '|' if raw.last_cell_closed else '' + closed = "|" if raw.last_cell_closed else "" return ( raw.linenum, f"{' ' * raw.indentation}|{'|'.join(raw.cells)}{closed}{raw.suffix}", @@ -1774,7 +2052,9 @@ def parse_headline(hl, doc, parent) -> Headline: contents = parse_contents(hl["contents"]) if not (isinstance(parent, OrgDoc) or depth > parent.depth): - raise AssertionError("Incorrectly parsed parent on `{}' > `{}'".format(parent.title, title)) + raise AssertionError( + "Incorrectly parsed parent on `{}' > `{}'".format(parent.title, title) + ) headline = Headline( start_line=hl["linenum"], @@ -1892,7 +2172,7 @@ class OrgDoc: Created by org-roam v2. """ for p in self.properties: - if p.key == 'ID': + if p.key == "ID": return p.value return None @@ -2112,9 +2392,14 @@ class OrgDocReader: self.headline_hierarchy.append(headline) if all([hl is not None for hl in self.headline_hierarchy]): - if not ([ len(cast(HeadlineDict, hl)['orig'].group('stars')) for hl in self.headline_hierarchy ] - == list(range(1, len(self.headline_hierarchy) + 1))): - raise AssertionError('Error on Headline Hierarchy') + if not ( + [ + len(cast(HeadlineDict, hl)["orig"].group("stars")) + for hl in self.headline_hierarchy + ] + == list(range(1, len(self.headline_hierarchy) + 1)) + ): + raise AssertionError("Error on Headline Hierarchy") else: # This might happen if headlines with more that 1 level deeper are found pass @@ -2135,9 +2420,13 @@ class OrgDocReader: checkbox_indentation=match.group("checkbox_indentation"), checkbox_value=match.group("checkbox_value"), tag_indentation=match.group("tag_indentation"), - tag=parse_content_block( - [RawLine(linenum=linenum, line=match.group("tag"))] - ).contents if match.group("tag") else None, + tag=( + parse_content_block( + [RawLine(linenum=linenum, line=match.group("tag"))] + ).contents + if match.group("tag") + else None + ), content=parse_content_block( [RawLine(linenum=linenum, line=match.group("content"))] ).contents, @@ -2151,14 +2440,14 @@ class OrgDocReader: return li def add_table_line(self, linenum: int, line: str): - chunks = line.split('|') + chunks = line.split("|") indentation = len(chunks[0]) - if chunks[-1].strip() == '': + if chunks[-1].strip() == "": suffix = chunks[-1] cells = chunks[1:-1] last_cell_closed = True else: - suffix = '' + suffix = "" cells = chunks[1:] last_cell_closed = False @@ -2200,8 +2489,13 @@ class OrgDocReader: self.headline_hierarchy[-1]["contents"].append(raw) def add_begin_block_line(self, linenum: int, match: re.Match): - line = DelimiterLine(linenum, match.group(0), DelimiterLineType.BEGIN_BLOCK, - BlockDelimiterTypeData(match.group("subtype")), match.group('arguments')) + line = DelimiterLine( + linenum, + match.group(0), + DelimiterLineType.BEGIN_BLOCK, + BlockDelimiterTypeData(match.group("subtype")), + match.group("arguments"), + ) if len(self.headline_hierarchy) == 0: self.delimiters.append(line) else: @@ -2209,8 +2503,13 @@ class OrgDocReader: self.headline_hierarchy[-1]["delimiters"].append(line) def add_end_block_line(self, linenum: int, match: re.Match): - line = DelimiterLine(linenum, match.group(0), DelimiterLineType.END_BLOCK, - BlockDelimiterTypeData(match.group("subtype")), None) + line = DelimiterLine( + linenum, + match.group(0), + DelimiterLineType.END_BLOCK, + BlockDelimiterTypeData(match.group("subtype")), + None, + ) if len(self.headline_hierarchy) == 0: self.delimiters.append(line) else: @@ -2272,8 +2571,8 @@ class OrgDocReader: nonlocal list_item nonlocal list_item_indentation if list_item: - if ((line[:list_item.text_start_pos].strip() == '') - or (len(line.strip()) == 0) + if (line[: list_item.text_start_pos].strip() == "") or ( + len(line.strip()) == 0 ): list_item.append_line(line) added = True @@ -2336,7 +2635,7 @@ class OrgDocReader: list_item = None elif m := NODE_PROPERTIES_RE.match(line): self.add_node_properties_line(linenum, m) - elif line.strip().startswith('|'): + elif line.strip().startswith("|"): self.add_table_line(linenum, line) list_item_indentation = None list_item = None @@ -2382,7 +2681,9 @@ def loads(s, environment=BASE_ENVIRONMENT, extra_cautious=True): context_last_line = None # print("---\n" + after_dump + "\n---") - raise NonReproducibleDocument("Difference found between existing version and dumped") + raise NonReproducibleDocument( + "Difference found between existing version and dumped" + ) return doc diff --git a/org_rw/types.py b/org_rw/types.py index eff7f59..7bda704 100644 --- a/org_rw/types.py +++ b/org_rw/types.py @@ -1,6 +1,7 @@ import re from typing import List, TypedDict + class HeadlineDict(TypedDict): linenum: int orig: re.Match diff --git a/tests/test_org.py b/tests/test_org.py index 8631fba..f8a1dbb 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -1,14 +1,23 @@ -import logging import os import unittest -from datetime import date from datetime import datetime as DT from org_rw import MarkerToken, MarkerType, Timestamp, dumps, load, loads, dom import org_rw -from utils.assertions import (BOLD, CODE, HL, ITALIC, SPAN, STRIKE, UNDERLINED, - VERBATIM, WEB_LINK, Doc, Tokens) +from utils.assertions import ( + BOLD, + CODE, + HL, + ITALIC, + SPAN, + STRIKE, + UNDERLINED, + VERBATIM, + WEB_LINK, + Doc, + Tokens, +) DIR = os.path.dirname(os.path.abspath(__file__)) @@ -283,13 +292,19 @@ class TestSerde(unittest.TestCase): SPAN("\n"), SPAN( " This is a ", - WEB_LINK("[tricky web link]\u200b", "https://codigoparallevar.com/4"), + WEB_LINK( + "[tricky web link]\u200b", + "https://codigoparallevar.com/4", + ), " followed up with some text.\n", ), SPAN("\n"), SPAN( " This is [", - WEB_LINK("another tricky web link", "https://codigoparallevar.com/5"), + WEB_LINK( + "another tricky web link", + "https://codigoparallevar.com/5", + ), "] followed up with some text.\n", ), ], @@ -306,7 +321,7 @@ class TestSerde(unittest.TestCase): ), ], ), - ) + ), ) ex.assert_matches(self, doc) @@ -471,7 +486,9 @@ class TestSerde(unittest.TestCase): + 'echo "with two lines"\n' + "exit 0 # Exit successfully", ) - self.assertEqual(snippets[0].arguments.split(), ['shell', ':results', 'verbatim']) + self.assertEqual( + snippets[0].arguments.split(), ["shell", ":results", "verbatim"] + ) self.assertEqual( snippets[0].result, "This is a test\n" + "with two lines", @@ -489,10 +506,10 @@ class TestSerde(unittest.TestCase): self.assertEqual( snippets[2].content, - '/* This code has to be escaped to\n' - + ' * avoid confusion with new headlines.\n' - + ' */\n' - + 'main(){}', + "/* This code has to be escaped to\n" + + " * avoid confusion with new headlines.\n" + + " */\n" + + "main(){}", ) def test_mimic_write_file_05(self): @@ -530,7 +547,7 @@ class TestSerde(unittest.TestCase): hl_schedule_range = hl.children[1] self.assertEqual( hl_schedule_range.scheduled.time, - Timestamp(True, 2020, 12, 15, "Mar", 0, 5, '++1w') + Timestamp(True, 2020, 12, 15, "Mar", 0, 5, "++1w"), ) self.assertEqual( hl_schedule_range.scheduled.end_time, @@ -538,7 +555,7 @@ class TestSerde(unittest.TestCase): ) self.assertEqual( hl_schedule_range.scheduled.repetition, - '++1w', + "++1w", ) def test_update_info_file_05(self): @@ -591,7 +608,8 @@ class TestSerde(unittest.TestCase): MarkerToken(closing=False, tok_type=MarkerType.UNDERLINED_MODE), "markup", MarkerToken(closing=True, tok_type=MarkerType.UNDERLINED_MODE), - ".", "\n" + ".", + "\n", ], ) @@ -625,12 +643,24 @@ class TestSerde(unittest.TestCase): print(lists4) self.assertEqual(len(lists4), 2) - self.assertEqual(lists4[0][0].content, ["This is a list item...", "\n that spans multiple lines", "\n"]) + self.assertEqual( + lists4[0][0].content, + ["This is a list item...", "\n that spans multiple lines", "\n"], + ) self.assertEqual(lists4[0][0].bullet, "-") - self.assertEqual(lists4[0][1].content, ["This is another list item...", "\n that has content on multiple lines", "\n"]) + self.assertEqual( + lists4[0][1].content, + [ + "This is another list item...", + "\n that has content on multiple lines", + "\n", + ], + ) self.assertEqual(lists4[0][1].bullet, "-") - self.assertEqual(lists4[1][0].content, ["This is another", "\n multiline list", "\n"]) + self.assertEqual( + lists4[1][0].content, ["This is another", "\n multiline list", "\n"] + ) self.assertEqual(lists4[1][0].bullet, "-") def test_org_roam_07(self): @@ -674,20 +704,22 @@ class TestSerde(unittest.TestCase): """.strip(), ) - def test_markup_file_09(self): with open(os.path.join(DIR, "09-markup-on-headline.org")) as f: doc = load(f) hl = doc.getTopHeadlines()[0] print(hl.title) - self.assertEqual(hl.title.contents, [ - 'Headline ', - MarkerToken(closing=False, tok_type=MarkerType.UNDERLINED_MODE), - 'with', - MarkerToken(closing=True, tok_type=MarkerType.UNDERLINED_MODE), - ' markup', - ]) + self.assertEqual( + hl.title.contents, + [ + "Headline ", + MarkerToken(closing=False, tok_type=MarkerType.UNDERLINED_MODE), + "with", + MarkerToken(closing=True, tok_type=MarkerType.UNDERLINED_MODE), + " markup", + ], + ) def test_mimic_write_file_10(self): with open(os.path.join(DIR, "10-tables.org")) as f: @@ -708,9 +740,9 @@ class TestSerde(unittest.TestCase): print(first_table[0]) self.assertEqual(len(first_table[0].cells), 3) - self.assertEqual(first_table[0].cells[0].strip(), 'Header1') - self.assertEqual(first_table[0].cells[1].strip(), 'Header2') - self.assertEqual(first_table[0].cells[2].strip(), 'Header3') + self.assertEqual(first_table[0].cells[0].strip(), "Header1") + self.assertEqual(first_table[0].cells[1].strip(), "Header2") + self.assertEqual(first_table[0].cells[2].strip(), "Header3") hl = hl.children[0] @@ -720,9 +752,9 @@ class TestSerde(unittest.TestCase): print(first_table[0]) self.assertEqual(len(first_table[0].cells), 3) - self.assertEqual(first_table[0].cells[0].strip(), 'Header1') - self.assertEqual(first_table[0].cells[1].strip(), 'Header2') - self.assertEqual(first_table[0].cells[2].strip(), 'Header3') + self.assertEqual(first_table[0].cells[0].strip(), "Header1") + self.assertEqual(first_table[0].cells[1].strip(), "Header2") + self.assertEqual(first_table[0].cells[2].strip(), "Header3") def test_tables_html_file_10(self): with open(os.path.join(DIR, "10-tables.org")) as f: @@ -732,27 +764,26 @@ class TestSerde(unittest.TestCase): tree = hl.as_dom() non_props = [ - item - for item in tree - if not isinstance(item, dom.PropertyDrawerNode) + item for item in tree if not isinstance(item, dom.PropertyDrawerNode) ] - self.assertTrue(isinstance(non_props[0], dom.Text) - and isinstance(non_props[1], dom.TableNode) - and isinstance(non_props[2], dom.Text), - 'Expected ') - + self.assertTrue( + isinstance(non_props[0], dom.Text) + and isinstance(non_props[1], dom.TableNode) + and isinstance(non_props[2], dom.Text), + "Expected
", + ) hl = hl.children[0] tree = hl.as_dom() non_props = [ item for item in tree - if not (isinstance(item, dom.PropertyDrawerNode) - or isinstance(item, dom.Text)) + if not ( + isinstance(item, dom.PropertyDrawerNode) or isinstance(item, dom.Text) + ) ] print_tree(non_props) - self.assertTrue(len(non_props) == 1, - 'Expected , with only (1) element') + self.assertTrue(len(non_props) == 1, "Expected , with only (1) element") def test_nested_lists_html_file_11(self): with open(os.path.join(DIR, "11-nested-lists.org")) as f: @@ -762,30 +793,38 @@ class TestSerde(unittest.TestCase): tree = hl.as_dom() non_props = [ - item - for item in tree - if not isinstance(item, dom.PropertyDrawerNode) + item for item in tree if not isinstance(item, dom.PropertyDrawerNode) ] print_tree(non_props) - self.assertTrue((len(non_props) == 1) and (isinstance(non_props[0], dom.ListGroupNode)), - 'Expected only as top level') + self.assertTrue( + (len(non_props) == 1) and (isinstance(non_props[0], dom.ListGroupNode)), + "Expected only as top level", + ) dom_list = non_props[0] children = dom_list.children - self.assertTrue(len(children) == 5, 'Expected 5 items inside , 3 texts and 2 sublists') + self.assertTrue( + len(children) == 5, "Expected 5 items inside , 3 texts and 2 sublists" + ) # Assert texts - self.assertEqual(children[0].content, ['1']) - self.assertEqual(children[2].content, ['2']) - self.assertEqual(children[4].content[0], '3') # Might be ['3', '\n'] but shouldn't be a breaking change + self.assertEqual(children[0].content, ["1"]) + self.assertEqual(children[2].content, ["2"]) + self.assertEqual( + children[4].content[0], "3" + ) # Might be ['3', '\n'] but shouldn't be a breaking change # Assert lists - self.assertTrue(isinstance(children[1], dom.ListGroupNode), 'Expected sublist inside "1"') - self.assertEqual(children[1].children[0].content, ['1.1']) - self.assertEqual(children[1].children[1].content, ['1.2']) - self.assertTrue(isinstance(children[3], dom.ListGroupNode), 'Expected sublist inside "2"') - self.assertEqual(children[3].children[0].content, ['2.1']) - self.assertEqual(children[3].children[1].content, ['2.2']) + self.assertTrue( + isinstance(children[1], dom.ListGroupNode), 'Expected sublist inside "1"' + ) + self.assertEqual(children[1].children[0].content, ["1.1"]) + self.assertEqual(children[1].children[1].content, ["1.2"]) + self.assertTrue( + isinstance(children[3], dom.ListGroupNode), 'Expected sublist inside "2"' + ) + self.assertEqual(children[3].children[0].content, ["2.1"]) + self.assertEqual(children[3].children[1].content, ["2.2"]) def test_mimic_write_file_12(self): with open(os.path.join(DIR, "12-headlines-with-skip-levels.org")) as f: @@ -812,6 +851,10 @@ def print_element(element, indentation, headline): if isinstance(element, org_rw.Link): print(" " * indentation * 2, "Link:", element.get_raw()) elif isinstance(element, str): - print(" " * indentation * 2, "Str[" + element.replace('\n', '') + "]", type(element)) + print( + " " * indentation * 2, + "Str[" + element.replace("\n", "") + "]", + type(element), + ) else: print_tree(element, indentation, headline) From f640521b560e66d7679e0d44015de72eff7c5bb6 Mon Sep 17 00:00:00 2001 From: Lyz Date: Sat, 20 Jul 2024 11:14:15 +0200 Subject: [PATCH 17/55] feat: add the scheduled, deadline and closed arguments to Headline init style: Improve the type hints of Time When reading them it's more natural to read Optional[Time] than to assume that None is part of the Union in Time --- org_rw/org_rw.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 0a8383e..ff6d2b3 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -302,6 +302,9 @@ class Headline: is_todo: bool, is_done: bool, spacing, + scheduled: Optional[Time] = None, + deadline: Optional[Time] = None, + closed: Optional[Time] = None, ): self.start_line = start_line self.depth = depth @@ -324,9 +327,9 @@ class Headline: self.parent = parent self.is_todo = is_todo self.is_done = is_done - self.scheduled: Time = None - self.deadline: Time = None - self.closed: Time = None + self.scheduled = scheduled + self.deadline = deadline + self.closed = closed self.spacing = spacing # Read planning line @@ -712,7 +715,7 @@ class Headline: time_seg = content[len("CLOCK:") :].strip() - parsed: Time = None + parsed: Optional[Time] = None if "--" in time_seg: # TODO: Consider duration start, end = time_seg.split("=")[0].split("--") @@ -1522,10 +1525,10 @@ def timestamp_to_string(ts: Timestamp, end_time: Optional[Timestamp] = None) -> return "[{}]".format(base) -Time = Union[None, TimeRange, OrgTime] +Time = Union[TimeRange, OrgTime] -def parse_time(value: str) -> Time: +def parse_time(value: str) -> Optional[Time]: if (value.count(">--<") == 1) or (value.count("]--[") == 1): # Time ranges with two different dates # @TODO properly consider "=> DURATION" section @@ -2151,7 +2154,9 @@ class OrgDoc: for keyword in keywords: if keyword.key in ("TODO", "SEQ_TODO"): - todo_kws, done_kws = re.sub(r"\([^)]+\)", "", keyword.value).split("|", 1) + todo_kws, done_kws = re.sub(r"\([^)]+\)", "", keyword.value).split( + "|", 1 + ) self.todo_keywords = re.sub(r"\s{2,}", " ", todo_kws.strip()).split() self.done_keywords = re.sub(r"\s{2,}", " ", done_kws.strip()).split() From be68d10d7a94f11b70050ee848f0342890bdf0e4 Mon Sep 17 00:00:00 2001 From: Lyz Date: Sat, 20 Jul 2024 11:38:19 +0200 Subject: [PATCH 18/55] feat: initialise a Timestamp from a datetime object --- org_rw/org_rw.py | 49 +++++++++++++++--------- tests/test_timestamp.py | 84 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 tests/test_timestamp.py diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index ff6d2b3..39fbef7 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1033,34 +1033,47 @@ class Timestamp: def __init__( self, active: bool, - year: int, - month: int, - day: int, - dow: Optional[str], - hour: Optional[int], - minute: Optional[int], + year: Optional[int] = None, + month: Optional[int] = None, + day: Optional[int] = None, + dow: Optional[str] = None, + hour: Optional[int] = None, + minute: Optional[int] = None, repetition: Optional[str] = None, + datetime_: Optional[Union[date, datetime]] = None, ): """ Initializes a Timestamp instance. Args: active (bool): Whether the timestamp is active. - year (int): The year of the timestamp. - month (int): The month of the timestamp. - day (int): The day of the timestamp. + year (Optional[int]): The year of the timestamp. + month (Optional[int]): The month of the timestamp. + day (Optional[int]): The day of the timestamp. dow (Optional[str]): The day of the week, if any. hour (Optional[int]): The hour of the timestamp, if any. minute (Optional[int]): The minute of the timestamp, if any. repetition (Optional[str]): The repetition pattern, if any. + datetime_ (Optional[Union[date, datetime]]): A date or datetime object. + + Raises: + ValueError: If neither datetime_ nor the combination of year, month, and day are provided. """ self.active = active - self._year = year - self._month = month - self._day = day - self.dow = dow - self.hour = hour - self.minute = minute + + if datetime_ is not None: + self.from_datetime(datetime_) + elif year is not None and month is not None and day is not None: + self._year = year + self._month = month + self._day = day + self.dow = dow + self.hour = hour + self.minute = minute + else: + raise ValueError( + "Either datetime_ or year, month, and day must be provided." + ) self.repetition = repetition def to_datetime(self) -> datetime: @@ -1475,16 +1488,16 @@ class OrgTime: """ self.time.active = False - def set_datetime(self, dt: datetime) -> None: + def from_datetime(self, dt: datetime) -> None: """ Updates the timestamp to use the given datetime. Args: dt (datetime): The datetime to update the timestamp with. """ - self.time = Timestamp.from_datetime(dt) + self.time.from_datetime(dt) if self.end_time: - self.end_time = Timestamp.from_datetime(dt) + self.end_time.from_datetime(dt) def time_from_str(s: str) -> Optional[OrgTime]: diff --git a/tests/test_timestamp.py b/tests/test_timestamp.py new file mode 100644 index 0000000..14ceeac --- /dev/null +++ b/tests/test_timestamp.py @@ -0,0 +1,84 @@ +"""Test the Timestamp object.""" + +import pytest +from datetime import date, datetime +from org_rw import Timestamp + + +def test_init_with_datetime() -> None: + datetime_obj: datetime = datetime(2024, 7, 20, 15, 45) + + ts: Timestamp = Timestamp(active=True, datetime_=datetime_obj) + + assert ts.active is True + assert ts._year == 2024 + assert ts._month == 7 + assert ts._day == 20 + assert ts.hour == 15 + assert ts.minute == 45 + assert ts.dow is None + assert ts.repetition is None + + +def test_init_with_date() -> None: + date_obj: date = date(2024, 7, 20) + + ts: Timestamp = Timestamp(active=True, datetime_=date_obj) + + assert ts.active is True + assert ts._year == 2024 + assert ts._month == 7 + assert ts._day == 20 + assert ts.hour is None + assert ts.minute is None + assert ts.dow is None + assert ts.repetition is None + + +def test_init_with_year_month_day() -> None: + ts: Timestamp = Timestamp( + active=True, + year=2024, + month=7, + day=20, + hour=15, + minute=45, + dow="Saturday", + repetition="Weekly", + ) + + assert ts.active is True + assert ts._year == 2024 + assert ts._month == 7 + assert ts._day == 20 + assert ts.hour == 15 + assert ts.minute == 45 + assert ts.dow == "Saturday" + assert ts.repetition == "Weekly" + + +def test_init_without_required_arguments() -> None: + with pytest.raises(ValueError): + Timestamp(active=True) + + +def test_init_with_partial_date_info() -> None: + with pytest.raises(ValueError): + Timestamp(active=True, year=2024, month=7) + + +def test_init_with_datetime_overrides_date_info() -> None: + datetime_obj: datetime = datetime(2024, 7, 20, 15, 45) + + ts: Timestamp = Timestamp( + active=True, year=2020, month=1, day=1, datetime_=datetime_obj + ) + + assert ts.active is True + assert ts._year == 2024 + assert ts._month == 7 + assert ts._day == 20 + assert ts.hour == 15 + assert ts.minute == 45 + assert ts.dow is None + assert ts.repetition is None From ff841f82f0548c277ad7284e541c2385d1013ff5 Mon Sep 17 00:00:00 2001 From: Lyz Date: Sat, 20 Jul 2024 11:41:15 +0200 Subject: [PATCH 19/55] feat: Set the default Timestamp active to True That way you don't need to specify it if you don't want --- org_rw/org_rw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 39fbef7..4bd7e04 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1032,7 +1032,7 @@ TableRow = collections.namedtuple( class Timestamp: def __init__( self, - active: bool, + active: bool = True, year: Optional[int] = None, month: Optional[int] = None, day: Optional[int] = None, From f4d63c2f932d4787b4624dde7168073844b65468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sat, 20 Jul 2024 14:41:04 +0200 Subject: [PATCH 20/55] Add (failing) test. --- tests/test_org.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_org.py b/tests/test_org.py index 8631fba..0a4acc7 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -794,6 +794,21 @@ class TestSerde(unittest.TestCase): self.assertEqual(dumps(doc), orig) + def test_add_todo_keywords_programatically(self): + orig = '''* NEW_TODO_STATE First entry + +* NEW_DONE_STATE Second entry''' + doc = loads(orig, environment={ + 'org-todo-keywords': "NEW_TODO_STATE | NEW_DONE_STATE" + }) + self.assertEqual(doc.headlines[0].is_todo, True) + self.assertEqual(doc.headlines[0].is_done, False) + + self.assertEqual(doc.headlines[1].is_todo, False) + self.assertEqual(doc.headlines[1].is_done, True) + + self.assertEqual(dumps(doc), orig) + def print_tree(tree, indentation=0, headline=None): for element in tree: From 4c169f5d4757a091e88a3e352f974a4222062f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sat, 20 Jul 2024 14:42:03 +0200 Subject: [PATCH 21/55] Add (passing) test to read TODO/DONE states from file. --- tests/test_org.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_org.py b/tests/test_org.py index 0a4acc7..9d2b735 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -797,6 +797,23 @@ class TestSerde(unittest.TestCase): def test_add_todo_keywords_programatically(self): orig = '''* NEW_TODO_STATE First entry +* NEW_DONE_STATE Second entry''' + doc = loads(orig, environment={ + 'org-todo-keywords': "NEW_TODO_STATE | NEW_DONE_STATE" + }) + self.assertEqual(doc.headlines[0].is_todo, True) + self.assertEqual(doc.headlines[0].is_done, False) + + self.assertEqual(doc.headlines[1].is_todo, False) + self.assertEqual(doc.headlines[1].is_done, True) + + self.assertEqual(dumps(doc), orig) + + def test_add_todo_keywords_in_file(self): + orig = '''#+TODO: NEW_TODO_STATE | NEW_DONE_STATE + +* NEW_TODO_STATE First entry + * NEW_DONE_STATE Second entry''' doc = loads(orig, environment={ 'org-todo-keywords': "NEW_TODO_STATE | NEW_DONE_STATE" From da2d8c8c6db6bd5d4c6870026088999b40cb6e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sat, 20 Jul 2024 14:42:41 +0200 Subject: [PATCH 22/55] Add `org-todo-keywords` environment to programatically set states. --- org_rw/org_rw.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index c0a1244..3a775a3 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -17,8 +17,12 @@ from . import dom DEBUG_DIFF_CONTEXT = 10 +DEFAULT_TODO_KEYWORDS = ["TODO"] +DEFAULT_DONE_KEYWORDS = ["DONE"] + BASE_ENVIRONMENT = { "org-footnote-section": "Footnotes", + "org-todo-keywords": ' '.join(DEFAULT_TODO_KEYWORDS) + ' | ' + ' '.join(DEFAULT_DONE_KEYWORDS), "org-options-keywords": ( "ARCHIVE:", "AUTHOR:", @@ -52,9 +56,6 @@ BASE_ENVIRONMENT = { ), } -DEFAULT_TODO_KEYWORDS = ["TODO"] -DEFAULT_DONE_KEYWORDS = ["DONE"] - HEADLINE_TAGS_RE = re.compile(r"((:(\w|[0-9_@#%])+)+:)\s*$") HEADLINE_RE = re.compile(r"^(?P\*+)(?P\s+)(?P.*?)$") KEYWORDS_RE = re.compile( @@ -1864,17 +1865,27 @@ def dump_delimiters(line: DelimiterLine): class OrgDoc: def __init__( - self, headlines, keywords, contents, list_items, structural, properties + self, headlines, keywords, contents, list_items, structural, properties, + environment=BASE_ENVIRONMENT, ): self.todo_keywords = DEFAULT_TODO_KEYWORDS self.done_keywords = DEFAULT_DONE_KEYWORDS + keywords_set_in_file = False for keyword in keywords: if keyword.key in ("TODO", "SEQ_TODO"): todo_kws, done_kws = re.sub(r"\([^)]+\)", "", keyword.value).split("|", 1) self.todo_keywords = re.sub(r"\s{2,}", " ", todo_kws.strip()).split() self.done_keywords = re.sub(r"\s{2,}", " ", done_kws.strip()).split() + keywords_set_in_file = True + + if not keywords_set_in_file and 'org-todo-keywords' in environment: + # Read keywords from environment + todo_kws, done_kws = re.sub(r"\([^)]+\)", "", environment['org-todo-keywords']).split("|", 1) + + self.todo_keywords = re.sub(r"\s{2,}", " ", todo_kws.strip()).split() + self.done_keywords = re.sub(r"\s{2,}", " ", done_kws.strip()).split() self.keywords: List[Property] = keywords self.contents: List[RawLine] = contents @@ -2050,7 +2061,7 @@ class OrgDoc: class OrgDocReader: - def __init__(self): + def __init__(self, environment=BASE_ENVIRONMENT): self.headlines: List[HeadlineDict] = [] self.keywords: List[Keyword] = [] self.headline_hierarchy: List[Optional[HeadlineDict]] = [] @@ -2061,6 +2072,7 @@ class OrgDocReader: self.structural: List = [] self.properties: List = [] self.current_drawer: Optional[List] = None + self.environment = environment def finalize(self): return OrgDoc( @@ -2070,6 +2082,7 @@ class OrgDocReader: self.list_items, self.structural, self.properties, + self.environment, ) ## Construction @@ -2258,7 +2271,7 @@ class OrgDocReader: self.current_drawer.append(Property(linenum, match, key, value, None)) - def read(self, s, environment): + def read(self, s): lines = s.split("\n") line_count = len(lines) reader = enumerate(lines) @@ -2349,8 +2362,8 @@ class OrgDocReader: def loads(s, environment=BASE_ENVIRONMENT, extra_cautious=True): - reader = OrgDocReader() - reader.read(s, environment) + reader = OrgDocReader(environment) + reader.read(s) doc = reader.finalize() if extra_cautious: # Check that all options can be properly re-serialized after_dump = dumps(doc) From b174405c902cd5c1782c3fc0036431d5d349462d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sat, 20 Jul 2024 17:29:25 +0200 Subject: [PATCH 23/55] Refactor headline state parsing. - Add separate function to parse states. - Handle edge case when no `|` is used to split TODO and DONE states. - Add typing to the states to future-proof for handling keyboard shortcuts and actions on state changes. --- org_rw/org_rw.py | 65 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 3a775a3..939ae9b 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -9,7 +9,7 @@ import re import sys from datetime import date, datetime, timedelta from enum import Enum -from typing import cast, Iterator, List, Literal, Optional, Tuple, Union +from typing import cast, Iterator, List, Literal, Optional, Tuple, TypedDict, Union from .types import HeadlineDict @@ -107,6 +107,15 @@ CodeSnippet = collections.namedtuple("CodeSnippet", ("name", "content", "result" NON_FINISHED_GROUPS = (type(None), dom.ListGroupNode, dom.ResultsDrawerNode, dom.PropertyDrawerNode) FREE_GROUPS = (dom.CodeBlock,) +# States +class HeadlineState(TypedDict): + # To be extended to handle keyboard shortcuts + name: str + +class OrgDocDeclaredStates(TypedDict): + not_completed: List[HeadlineState] + completed: List[HeadlineState] + class NonReproducibleDocument(Exception): """ @@ -1759,16 +1768,16 @@ def parse_headline(hl, doc, parent) -> Headline: title = line is_done = is_todo = False for state in doc.todo_keywords or []: - if title.startswith(state + " "): + if title.startswith(state['name'] + " "): hl_state = state - title = title[len(state + " ") :] + title = title[len(state['name'] + " ") :] is_todo = True break else: for state in doc.done_keywords or []: - if title.startswith(state + " "): + if title.startswith(state['name'] + " "): hl_state = state - title = title[len(state + " ") :] + title = title[len(state['name'] + " ") :] is_done = True break @@ -1863,29 +1872,53 @@ def dump_delimiters(line: DelimiterLine): return (line.linenum, line.line) +def parse_todo_done_keywords(line: str) -> OrgDocDeclaredStates: + clean_line = re.sub(r"\([^)]+\)", "", line) + if '|' in clean_line: + todo_kws, done_kws = clean_line.split("|", 1) + has_split = True + else: + # Standard behavior in this case is: the last state is the one considered as DONE + todo_kws = clean_line + + todo_keywords = re.sub(r"\s{2,}", " ", todo_kws.strip()).split() + if has_split: + done_keywords = re.sub(r"\s{2,}", " ", done_kws.strip()).split() + else: + done_keywods = [todo_keywords[-1]] + todo_keywords = todo_keywords[:-1] + + return { + "not_completed": [ + HeadlineState(name=keyword) + for keyword in todo_keywords + ], + "completed": [ + HeadlineState(name=keyword) + for keyword in done_keywords + ], + } + + class OrgDoc: def __init__( self, headlines, keywords, contents, list_items, structural, properties, environment=BASE_ENVIRONMENT, ): - self.todo_keywords = DEFAULT_TODO_KEYWORDS - self.done_keywords = DEFAULT_DONE_KEYWORDS + self.todo_keywords = [HeadlineState(name=kw) for kw in DEFAULT_TODO_KEYWORDS] + self.done_keywords = [HeadlineState(name=kw) for kw in DEFAULT_DONE_KEYWORDS] keywords_set_in_file = False for keyword in keywords: if keyword.key in ("TODO", "SEQ_TODO"): - todo_kws, done_kws = re.sub(r"\([^)]+\)", "", keyword.value).split("|", 1) - - self.todo_keywords = re.sub(r"\s{2,}", " ", todo_kws.strip()).split() - self.done_keywords = re.sub(r"\s{2,}", " ", done_kws.strip()).split() + states = parse_todo_done_keywords(keyword.value) + self.todo_keywords, self.done_keywords = states['not_completed'], states['completed'] keywords_set_in_file = True if not keywords_set_in_file and 'org-todo-keywords' in environment: # Read keywords from environment - todo_kws, done_kws = re.sub(r"\([^)]+\)", "", environment['org-todo-keywords']).split("|", 1) - - self.todo_keywords = re.sub(r"\s{2,}", " ", todo_kws.strip()).split() - self.done_keywords = re.sub(r"\s{2,}", " ", done_kws.strip()).split() + states = parse_todo_done_keywords(environment['org-todo-keywords']) + self.todo_keywords, self.done_keywords = states['not_completed'], states['completed'] self.keywords: List[Property] = keywords self.contents: List[RawLine] = contents @@ -1960,7 +1993,7 @@ class OrgDoc: state = "" if headline.state: - state = headline.state + " " + state = headline.state['name'] + " " raw_title = token_list_to_raw(headline.title.contents) tags_padding = "" From 191bb753c46a84dc6b55ab489e44f2ce9b92b059 Mon Sep 17 00:00:00 2001 From: Lyz Date: Fri, 26 Jul 2024 13:34:38 +0200 Subject: [PATCH 24/55] tests: fix repetition string --- tests/test_timestamp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_timestamp.py b/tests/test_timestamp.py index 14ceeac..7d69d13 100644 --- a/tests/test_timestamp.py +++ b/tests/test_timestamp.py @@ -44,7 +44,7 @@ def test_init_with_year_month_day() -> None: hour=15, minute=45, dow="Saturday", - repetition="Weekly", + repetition=".+1d", ) assert ts.active is True @@ -54,7 +54,7 @@ def test_init_with_year_month_day() -> None: assert ts.hour == 15 assert ts.minute == 45 assert ts.dow == "Saturday" - assert ts.repetition == "Weekly" + assert ts.repetition == ".+1d" def test_init_without_required_arguments() -> None: From 09f9030818c67ec8b61fb4bc8240e7b9a54fb5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Mon, 29 Jul 2024 15:31:39 +0100 Subject: [PATCH 25/55] tests: fix typings to match mypy expectations. --- org_rw/org_rw.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index e5b992e..9b25ed9 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -364,12 +364,12 @@ class Headline: ) ] - if scheduled := m.group("scheduled"): - self.scheduled = parse_time(scheduled) - if closed := m.group("closed"): - self.closed = parse_time(closed) - if deadline := m.group("deadline"): - self.deadline = parse_time(deadline) + if scheduled_m := m.group("scheduled"): + self.scheduled = parse_time(scheduled_m) + if closed_m := m.group("closed"): + self.closed = parse_time(closed_m) + if deadline_m := m.group("deadline"): + self.deadline = parse_time(deadline_m) # Remove from contents self._remove_element_in_line(start_line + 1) @@ -1094,7 +1094,7 @@ class Timestamp: datetime: The corresponding datetime object. """ if self.hour is not None: - return datetime(self.year, self.month, self.day, self.hour, self.minute) + return datetime(self.year, self.month, self.day, self.hour, self.minute or 0) else: return datetime(self.year, self.month, self.day, 0, 0) @@ -1486,17 +1486,32 @@ class OrgTime: ) ) + @property + def active(self) -> bool: + """ + Checks if the time is set as active. + """ + return self.time.active + + + @active.setter + def active(self, value: bool) -> None: + """ + Sets the active state for the timestamp. + """ + self.time.active = value + def activate(self) -> None: """ Sets the active state for the timestamp. """ - self.time.active = True + self.active = True def deactivate(self) -> None: """ Sets the inactive state for the timestamp. """ - self.time.active = False + self.active = False def from_datetime(self, dt: datetime) -> None: """ @@ -1527,7 +1542,7 @@ def timestamp_to_string(ts: Timestamp, end_time: Optional[Timestamp] = None) -> if ts.hour is not None: base = "{date} {hour:02}:{minute:02d}".format( - date=date, hour=ts.hour, minute=ts.minute + date=date, hour=ts.hour, minute=ts.minute or 0 ) else: base = date From 75055f5e080ed3c606e92dbed53936acc1b8812c Mon Sep 17 00:00:00 2001 From: Lyz Date: Fri, 2 Aug 2024 20:08:04 +0200 Subject: [PATCH 26/55] feat: enhance type annotations and formatting feat: Added `py.typed` file to indicate the presence of type information in the package. Mypy needs this --- org_rw/org_rw.py | 127 +++++++++++++++++++++++++++++++++++------------ org_rw/py.typed | 0 2 files changed, 96 insertions(+), 31 deletions(-) create mode 100644 org_rw/py.typed diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 9b25ed9..cefbe5e 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Optional +from typing import Dict, Optional, TextIO from datetime import timedelta import collections import difflib @@ -23,7 +23,9 @@ DEFAULT_DONE_KEYWORDS = ["DONE"] BASE_ENVIRONMENT = { "org-footnote-section": "Footnotes", - "org-todo-keywords": ' '.join(DEFAULT_TODO_KEYWORDS) + ' | ' + ' '.join(DEFAULT_DONE_KEYWORDS), + "org-todo-keywords": " ".join(DEFAULT_TODO_KEYWORDS) + + " | " + + " ".join(DEFAULT_DONE_KEYWORDS), "org-options-keywords": ( "ARCHIVE:", "AUTHOR:", @@ -115,11 +117,13 @@ NON_FINISHED_GROUPS = ( ) FREE_GROUPS = (dom.CodeBlock,) + # States class HeadlineState(TypedDict): # To be extended to handle keyboard shortcuts name: str + class OrgDocDeclaredStates(TypedDict): not_completed: List[HeadlineState] completed: List[HeadlineState] @@ -1094,7 +1098,9 @@ class Timestamp: datetime: The corresponding datetime object. """ if self.hour is not None: - return datetime(self.year, self.month, self.day, self.hour, self.minute or 0) + return datetime( + self.year, self.month, self.day, self.hour, self.minute or 0 + ) else: return datetime(self.year, self.month, self.day, 0, 0) @@ -1493,7 +1499,6 @@ class OrgTime: """ return self.time.active - @active.setter def active(self, value: bool) -> None: """ @@ -1668,7 +1673,7 @@ class Text: def __repr__(self): return "{{Text line: {}; content: {} }}".format(self.linenum, self.contents) - def get_text(self): + def get_text(self) -> str: return token_list_to_plaintext(self.contents) def get_raw(self): @@ -1991,7 +1996,7 @@ def parse_contents(raw_contents: List[RawLine]): return [parse_content_block(block) for block in blocks] -def parse_content_block(raw_contents: Union[List[RawLine], str]): +def parse_content_block(raw_contents: Union[List[RawLine], str]) -> Text: contents_buff = [] if isinstance(raw_contents, str): contents_buff.append(raw_contents) @@ -2077,16 +2082,16 @@ def parse_headline(hl, doc, parent) -> Headline: title = line is_done = is_todo = False for state in doc.todo_keywords or []: - if title.startswith(state['name'] + " "): + if title.startswith(state["name"] + " "): hl_state = state - title = title[len(state['name'] + " ") :] + title = title[len(state["name"] + " ") :] is_todo = True break else: for state in doc.done_keywords or []: - if title.startswith(state['name'] + " "): + if title.startswith(state["name"] + " "): hl_state = state - title = title[len(state['name'] + " ") :] + title = title[len(state["name"] + " ") :] is_done = True break @@ -2185,7 +2190,7 @@ def dump_delimiters(line: DelimiterLine): def parse_todo_done_keywords(line: str) -> OrgDocDeclaredStates: clean_line = re.sub(r"\([^)]+\)", "", line) - if '|' in clean_line: + if "|" in clean_line: todo_kws, done_kws = clean_line.split("|", 1) has_split = True else: @@ -2200,20 +2205,20 @@ def parse_todo_done_keywords(line: str) -> OrgDocDeclaredStates: todo_keywords = todo_keywords[:-1] return { - "not_completed": [ - HeadlineState(name=keyword) - for keyword in todo_keywords - ], - "completed": [ - HeadlineState(name=keyword) - for keyword in done_keywords - ], + "not_completed": [HeadlineState(name=keyword) for keyword in todo_keywords], + "completed": [HeadlineState(name=keyword) for keyword in done_keywords], } class OrgDoc: def __init__( - self, headlines, keywords, contents, list_items, structural, properties, + self, + headlines, + keywords, + contents, + list_items, + structural, + properties, environment=BASE_ENVIRONMENT, ): self.todo_keywords = [HeadlineState(name=kw) for kw in DEFAULT_TODO_KEYWORDS] @@ -2223,13 +2228,19 @@ class OrgDoc: for keyword in keywords: if keyword.key in ("TODO", "SEQ_TODO"): states = parse_todo_done_keywords(keyword.value) - self.todo_keywords, self.done_keywords = states['not_completed'], states['completed'] + self.todo_keywords, self.done_keywords = ( + states["not_completed"], + states["completed"], + ) keywords_set_in_file = True - if not keywords_set_in_file and 'org-todo-keywords' in environment: + if not keywords_set_in_file and "org-todo-keywords" in environment: # Read keywords from environment - states = parse_todo_done_keywords(environment['org-todo-keywords']) - self.todo_keywords, self.done_keywords = states['not_completed'], states['completed'] + states = parse_todo_done_keywords(environment["org-todo-keywords"]) + self.todo_keywords, self.done_keywords = ( + states["not_completed"], + states["completed"], + ) self.keywords: List[Property] = keywords self.contents: List[RawLine] = contents @@ -2304,7 +2315,7 @@ class OrgDoc: state = "" if headline.state: - state = headline.state['name'] + " " + state = headline.state["name"] + " " raw_title = token_list_to_raw(headline.title.contents) tags_padding = "" @@ -2418,7 +2429,7 @@ class OrgDocReader: self.current_drawer: Optional[List] = None self.environment = environment - def finalize(self): + def finalize(self) -> OrgDoc: return OrgDoc( self.headlines, self.keywords, @@ -2724,7 +2735,26 @@ class OrgDocReader: raise -def loads(s, environment=BASE_ENVIRONMENT, extra_cautious=True): +def loads( + s: str, environment: Optional[Dict] = BASE_ENVIRONMENT, extra_cautious: bool = True +) -> OrgDoc: + """ + Load an Org-mode document from a string. + + Args: + s (str): The string representation of the Org-mode document. + environment (Optional[dict]): The environment for parsing. Defaults to + `BASE_ENVIRONMENT`. + extra_cautious (bool): If True, perform an extra check to ensure that + the document can be re-serialized to the original string. Defaults to True. + + Returns: + OrgDoc: The loaded Org-mode document. + + Raises: + NonReproducibleDocument: If `extra_cautious` is True and there is a + difference between the original string and the re-serialized document. + """ reader = OrgDocReader(environment) reader.read(s) doc = reader.finalize() @@ -2764,20 +2794,55 @@ def loads(s, environment=BASE_ENVIRONMENT, extra_cautious=True): return doc -def load(f, environment=BASE_ENVIRONMENT, extra_cautious=False): +def load( + f: TextIO, + environment: Optional[dict] = BASE_ENVIRONMENT, + extra_cautious: bool = False, +) -> OrgDoc: + """ + Load an Org-mode document from a file object. + + Args: + f (TextIO): The file object containing the Org-mode document. + environment (Optional[dict]): The environment for parsing. Defaults to + `BASE_ENVIRONMENT`. + extra_cautious (bool): If True, perform an extra check to ensure that + the document can be re-serialized to the original string. Defaults to False. + + Returns: + OrgDoc: The loaded Org-mode document. + """ doc = loads(f.read(), environment, extra_cautious) doc._path = os.path.abspath(f.name) return doc -def dumps(doc): +def dumps(doc: OrgDoc) -> str: + """ + Serialize an OrgDoc object to a string. + + Args: + doc (OrgDoc): The OrgDoc object to serialize. + + Returns: + str: The serialized string representation of the OrgDoc object. + """ dump = list(doc.dump()) result = "\n".join(dump) - # print(result) return result -def dump(doc, fp): +def dump(doc: OrgDoc, fp: TextIO) -> None: + """ + Serialize an OrgDoc object to a file. + + Args: + doc (OrgDoc): The OrgDoc object to serialize. + fp (TextIO): The file-like object to write the serialized data to. + + Returns: + None + """ it = doc.dump() # Write first line separately diff --git a/org_rw/py.typed b/org_rw/py.typed new file mode 100644 index 0000000..e69de29 From 0a55c64551eb1c5a06c9245c253219d1861eaa58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 18 Aug 2024 22:30:18 +0200 Subject: [PATCH 27/55] test: Add formatting check to CI/CD. --- .gitea/workflows/tests.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitea/workflows/tests.yaml b/.gitea/workflows/tests.yaml index ee49a29..0e8a583 100644 --- a/.gitea/workflows/tests.yaml +++ b/.gitea/workflows/tests.yaml @@ -23,6 +23,16 @@ jobs: - run: pip install mypy - run: mypy org_rw --check-untyped-defs + style-formatting: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - run: apt-get update && apt-get install -y python3-pip + - run: pip install -e . + - run: pip install black + - run: black --check . + stability-extra-test: runs-on: ubuntu-latest steps: From 8ca480ad77c4eb2a31b09ba8a8722fcf02f49bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 18 Aug 2024 22:44:00 +0200 Subject: [PATCH 28/55] fix: Apply `black` formatter. --- org_rw/dom.py | 41 +++++++++++++++++++++++---------------- org_rw/utils.py | 15 ++++++++++++-- tests/test_org.py | 20 +++++++++---------- tests/utils/assertions.py | 13 +++++++++++-- 4 files changed, 58 insertions(+), 31 deletions(-) diff --git a/org_rw/dom.py b/org_rw/dom.py index cd8d63b..f9ed40f 100644 --- a/org_rw/dom.py +++ b/org_rw/dom.py @@ -41,11 +41,12 @@ class ListGroupNode: self.children.append(child) def get_raw(self): - return '\n'.join([c.get_raw() for c in self.children]) + return "\n".join([c.get_raw() for c in self.children]) def __repr__(self): return "".format(len(self.children)) + class TableNode: def __init__(self): self.children = [] @@ -56,21 +57,24 @@ class TableNode: def __repr__(self): return "".format(len(self.children)) + class TableSeparatorRow: def __init__(self, orig=None): self.orig = orig + class TableRow: def __init__(self, cells, orig=None): self.cells = cells self.orig = orig + class Text: def __init__(self, content): self.content = content def get_raw(self): - return ''.join(self.content.get_raw()) + return "".join(self.content.get_raw()) class ListItem: @@ -105,21 +109,24 @@ class CodeBlock(BlockNode): def __repr__(self): return "".format(len(self.lines or [])) -DomNode = Union[DrawerNode, - PropertyNode, - ListGroupNode, - TableNode, - TableSeparatorRow, - TableRow, - Text, - ListItem, - BlockNode, - ] -ContainerDomNode = Union[DrawerNode, - ListGroupNode, - TableNode, - BlockNode, - ] +DomNode = Union[ + DrawerNode, + PropertyNode, + ListGroupNode, + TableNode, + TableSeparatorRow, + TableRow, + Text, + ListItem, + BlockNode, +] + +ContainerDomNode = Union[ + DrawerNode, + ListGroupNode, + TableNode, + BlockNode, +] from .utils import get_raw_contents diff --git a/org_rw/utils.py b/org_rw/utils.py index 0e6f559..405c62b 100644 --- a/org_rw/utils.py +++ b/org_rw/utils.py @@ -1,7 +1,18 @@ import uuid -from .org_rw import (Bold, Code, Headline, Italic, Line, RawLine, ListItem, Strike, Text, - Underlined, Verbatim) +from .org_rw import ( + Bold, + Code, + Headline, + Italic, + Line, + RawLine, + ListItem, + Strike, + Text, + Underlined, + Verbatim, +) from .org_rw import dump_contents diff --git a/tests/test_org.py b/tests/test_org.py index e49c6cf..97b8a04 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -834,12 +834,12 @@ class TestSerde(unittest.TestCase): self.assertEqual(dumps(doc), orig) def test_add_todo_keywords_programatically(self): - orig = '''* NEW_TODO_STATE First entry + orig = """* NEW_TODO_STATE First entry -* NEW_DONE_STATE Second entry''' - doc = loads(orig, environment={ - 'org-todo-keywords': "NEW_TODO_STATE | NEW_DONE_STATE" - }) +* NEW_DONE_STATE Second entry""" + doc = loads( + orig, environment={"org-todo-keywords": "NEW_TODO_STATE | NEW_DONE_STATE"} + ) self.assertEqual(doc.headlines[0].is_todo, True) self.assertEqual(doc.headlines[0].is_done, False) @@ -849,14 +849,14 @@ class TestSerde(unittest.TestCase): self.assertEqual(dumps(doc), orig) def test_add_todo_keywords_in_file(self): - orig = '''#+TODO: NEW_TODO_STATE | NEW_DONE_STATE + orig = """#+TODO: NEW_TODO_STATE | NEW_DONE_STATE * NEW_TODO_STATE First entry -* NEW_DONE_STATE Second entry''' - doc = loads(orig, environment={ - 'org-todo-keywords': "NEW_TODO_STATE | NEW_DONE_STATE" - }) +* NEW_DONE_STATE Second entry""" + doc = loads( + orig, environment={"org-todo-keywords": "NEW_TODO_STATE | NEW_DONE_STATE"} + ) self.assertEqual(doc.headlines[0].is_todo, True) self.assertEqual(doc.headlines[0].is_done, False) diff --git a/tests/utils/assertions.py b/tests/utils/assertions.py index 59dc658..9012d49 100644 --- a/tests/utils/assertions.py +++ b/tests/utils/assertions.py @@ -2,8 +2,17 @@ import collections import unittest from datetime import datetime -from org_rw import (Bold, Code, Italic, Line, Strike, Text, Underlined, - Verbatim, get_raw_contents) +from org_rw import ( + Bold, + Code, + Italic, + Line, + Strike, + Text, + Underlined, + Verbatim, + get_raw_contents, +) def timestamp_to_datetime(ts): From c6d8575ae5533c110a2bbf6ea3e75a4fb0b8800c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 18 Aug 2024 22:47:42 +0200 Subject: [PATCH 29/55] test: Test sorted imports. --- .gitea/workflows/tests.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitea/workflows/tests.yaml b/.gitea/workflows/tests.yaml index 0e8a583..f56a490 100644 --- a/.gitea/workflows/tests.yaml +++ b/.gitea/workflows/tests.yaml @@ -33,6 +33,16 @@ jobs: - run: pip install black - run: black --check . + style-sorted-imports: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - run: apt-get update && apt-get install -y python3-pip + - run: pip install -e . + - run: pip install isort + - run: isort --profile black --check . + stability-extra-test: runs-on: ubuntu-latest steps: From 66b42e0b961e7236a9ea6b5908c31468c892fd01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 18 Aug 2024 22:49:06 +0200 Subject: [PATCH 30/55] feat: Add script to apply formatting tools. --- scripts/apply-formatting.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100755 scripts/apply-formatting.sh diff --git a/scripts/apply-formatting.sh b/scripts/apply-formatting.sh new file mode 100755 index 0000000..2f7486b --- /dev/null +++ b/scripts/apply-formatting.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -eu + +cd "`dirname $0`" +cd .. + +set -x + +isort --profile black . +black . From e991074346f6f2072f0272082c20c49133548f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 18 Aug 2024 22:49:33 +0200 Subject: [PATCH 31/55] fix: Apply import sorting. --- org_rw/org_rw.py | 20 ++++++++++++++------ org_rw/utils.py | 5 ++--- tests/test_org.py | 6 +++--- tests/test_timestamp.py | 4 +++- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index cefbe5e..970f641 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1,6 +1,5 @@ from __future__ import annotations -from typing import Dict, Optional, TextIO -from datetime import timedelta + import collections import difflib import logging @@ -9,12 +8,21 @@ import re import sys from datetime import date, datetime, timedelta from enum import Enum -from typing import cast, Iterator, List, Literal, Optional, Tuple, TypedDict, Union - -from .types import HeadlineDict +from typing import ( + Dict, + Iterator, + List, + Literal, + Optional, + TextIO, + Tuple, + TypedDict, + Union, + cast, +) from . import dom - +from .types import HeadlineDict DEBUG_DIFF_CONTEXT = 10 diff --git a/org_rw/utils.py b/org_rw/utils.py index 405c62b..5b8b4e5 100644 --- a/org_rw/utils.py +++ b/org_rw/utils.py @@ -6,16 +6,15 @@ from .org_rw import ( Headline, Italic, Line, - RawLine, ListItem, + RawLine, Strike, Text, Underlined, Verbatim, + dump_contents, ) -from .org_rw import dump_contents - def get_hl_raw_contents(doc: Headline) -> str: lines = [] diff --git a/tests/test_org.py b/tests/test_org.py index 97b8a04..6a54395 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -2,9 +2,6 @@ import os import unittest from datetime import datetime as DT -from org_rw import MarkerToken, MarkerType, Timestamp, dumps, load, loads, dom -import org_rw - from utils.assertions import ( BOLD, CODE, @@ -19,6 +16,9 @@ from utils.assertions import ( Tokens, ) +import org_rw +from org_rw import MarkerToken, MarkerType, Timestamp, dom, dumps, load, loads + DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/test_timestamp.py b/tests/test_timestamp.py index 7d69d13..f7e0eca 100644 --- a/tests/test_timestamp.py +++ b/tests/test_timestamp.py @@ -1,7 +1,9 @@ """Test the Timestamp object.""" -import pytest from datetime import date, datetime + +import pytest + from org_rw import Timestamp From 490b36887aebb9273bb5daf628eafd437fc79cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Thu, 22 Aug 2024 00:20:15 +0200 Subject: [PATCH 32/55] Require space before list item tag separator. --- org_rw/org_rw.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 970f641..5f49c26 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -103,7 +103,7 @@ PLANNING_RE = re.compile( r")+\s*" ) LIST_ITEM_RE = re.compile( - r"(?P\s*)((?P[*\-+])|((?P\d|[a-zA-Z])(?P[.)]))) ((?P\s*)\[(?P[ Xx])\])?((?P\s*)(?P.*?)::)?(?P.*)" + r"(?P\s*)((?P[*\-+])|((?P\d|[a-zA-Z])(?P[.)]))) ((?P\s*)\[(?P[ Xx])\])?((?P\s*)((?P.*?)\s::))?(?P.*)" ) IMPLICIT_LINK_RE = re.compile(r"(https?:[^<> ]*[a-zA-Z0-9])") @@ -2052,7 +2052,7 @@ def dump_contents(raw): content = "\n".join(content_lines) checkbox = f"[{raw.checkbox_value}]" if raw.checkbox_value else "" tag = ( - f"{raw.tag_indentation}{token_list_to_raw(raw.tag or '')}::" + f"{raw.tag_indentation}{token_list_to_raw(raw.tag or '')} ::" if raw.tag or raw.tag_indentation else "" ) From f31c64c2426b1400320f481042964fd61a05a265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Thu, 22 Aug 2024 00:20:44 +0200 Subject: [PATCH 33/55] Properly track which tokens are used for closing formats. --- org_rw/org_rw.py | 1 + 1 file changed, 1 insertion(+) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 5f49c26..b8dccac 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1962,6 +1962,7 @@ def tokenize_contents(contents: str) -> List[TokenItems]: cut_string() tokens.append((TOKEN_TYPE_CLOSE_MARKER, char)) has_changed = True + closes.remove(i) if not has_changed: text.append(char) From 5552b3324b1f0503129f678d4495ad36e121f695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Thu, 22 Aug 2024 00:21:02 +0200 Subject: [PATCH 34/55] Handle `]` which not close link descriptions or references. --- org_rw/org_rw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index b8dccac..6cc4cef 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1911,7 +1911,7 @@ def tokenize_contents(contents: str) -> List[TokenItems]: continue # Possible link close or open of description - if char == "]" and len(contents) > i + 1 and in_link: + if char == "]" and len(contents) > i + 1 and in_link and contents[i + 1] in "][": if contents[i + 1] == "]": cut_string() From 4af4cda44b84c798ac934b559aab68e99c3876d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Thu, 22 Aug 2024 00:26:11 +0200 Subject: [PATCH 35/55] Fix formatting. --- org_rw/org_rw.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 6cc4cef..99ac122 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1911,7 +1911,12 @@ def tokenize_contents(contents: str) -> List[TokenItems]: continue # Possible link close or open of description - if char == "]" and len(contents) > i + 1 and in_link and contents[i + 1] in "][": + if ( + char == "]" + and len(contents) > i + 1 + and in_link + and contents[i + 1] in "][" + ): if contents[i + 1] == "]": cut_string() From e0306bf3a58c435aac7451d0eaa6936bd436cb53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 1 Sep 2024 14:17:32 +0200 Subject: [PATCH 36/55] Add (failing) test for tags property read. --- tests/13-tags.org | 13 +++++++++++++ tests/test_org.py | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 tests/13-tags.org diff --git a/tests/13-tags.org b/tests/13-tags.org new file mode 100644 index 0000000..c61ccdf --- /dev/null +++ b/tests/13-tags.org @@ -0,0 +1,13 @@ +#+TITLE: 13-Tags +#+DESCRIPTION: Simple org file to test tags +#+FILETAGS: :filetag: + +* Level 1 :h1tag: + :PROPERTIES: + :ID: 13-tags + :CREATED: [2020-01-01 Wed 01:01] + :END: + +** Level2 :h2tag: +* Level 1-1 :otherh1tag: +** Level2 :otherh2tag: diff --git a/tests/test_org.py b/tests/test_org.py index 6a54395..c17f9d2 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -865,6 +865,29 @@ class TestSerde(unittest.TestCase): self.assertEqual(dumps(doc), orig) + def test_mimic_write_file_13(self): + with open(os.path.join(DIR, "13-tags.org")) as f: + orig = f.read() + doc = loads(orig) + + self.assertEqual(dumps(doc), orig) + + def test_tag_property_read_13(self): + with open(os.path.join(DIR, "13-tags.org")) as f: + orig = f.read() + doc = loads(orig) + + self.assertEqual(doc.tags, ['filetag']) + + h1_1, h1_2 = doc.getTopHeadlines() + self.assertEqual(sorted(h1_1.tags), ['filetag', 'h1tag']) + self.assertEqual(sorted(h1_2.tags), ['filetag', 'otherh1tag']) + + h1_1_h2 = h1_1.children[0] + self.assertEqual(sorted(h1_1_h2.tags), ['filetag', 'h1tag', 'h2tag']) + + h1_2_h2 = h1_2.children[0] + self.assertEqual(sorted(h1_2_h2.tags), ['filetag', 'otherh1tag', 'otherh2tag']) def print_tree(tree, indentation=0, headline=None): for element in tree: From 570e6bb76494d1d602b2ba0a34baa0345745dddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 1 Sep 2024 23:35:33 +0200 Subject: [PATCH 37/55] Implement OrgDoc `.tags`. --- org_rw/org_rw.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 99ac122..7c6d931 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -753,10 +753,7 @@ class Headline: @property def tags(self): - if isinstance(self.parent, OrgDoc): - return list(self.shallow_tags) - else: - return list(self.shallow_tags) + self.parent.tags + return list(self.shallow_tags) + self.parent.tags def add_tag(self, tag: str): self.shallow_tags.append(tag) @@ -2280,6 +2277,13 @@ class OrgDoc: def path(self): return self._path + @property + def tags(self) -> list[str]: + for kw in self.keywords: + if kw.key == "FILETAGS": + return kw.value.strip(':').split(':') + return [] + ## Querying def get_links(self): for headline in self.headlines: From 852f47237413a55fe4e3320b98a4bef2fcdb4fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 1 Sep 2024 23:37:26 +0200 Subject: [PATCH 38/55] Implement OrgDoc .shallow_tags . --- org_rw/org_rw.py | 4 ++++ tests/test_org.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 7c6d931..52257f9 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -2284,6 +2284,10 @@ class OrgDoc: return kw.value.strip(':').split(':') return [] + @property + def shallow_tags(self) -> list[str]: + return self.tags + ## Querying def get_links(self): for headline in self.headlines: diff --git a/tests/test_org.py b/tests/test_org.py index c17f9d2..d515168 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -889,6 +889,23 @@ class TestSerde(unittest.TestCase): h1_2_h2 = h1_2.children[0] self.assertEqual(sorted(h1_2_h2.tags), ['filetag', 'otherh1tag', 'otherh2tag']) + def test_shallow_tag_property_read_13(self): + with open(os.path.join(DIR, "13-tags.org")) as f: + orig = f.read() + doc = loads(orig) + + self.assertEqual(doc.shallow_tags, ['filetag']) + + h1_1, h1_2 = doc.getTopHeadlines() + self.assertEqual(sorted(h1_1.shallow_tags), ['h1tag']) + self.assertEqual(sorted(h1_2.shallow_tags), ['otherh1tag']) + + h1_1_h2 = h1_1.children[0] + self.assertEqual(sorted(h1_1_h2.shallow_tags), ['h2tag']) + + h1_2_h2 = h1_2.children[0] + self.assertEqual(sorted(h1_2_h2.shallow_tags), ['otherh2tag']) + def print_tree(tree, indentation=0, headline=None): for element in tree: print(" " * indentation * 2, "EL:", element) From 92078617fcffb694babd7c0efe2aea8d7bf0812a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 1 Sep 2024 23:46:10 +0200 Subject: [PATCH 39/55] Add tests and implement `org-tags-exclude-from-inheritance`. --- org_rw/org_rw.py | 10 ++++++++-- tests/test_org.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 52257f9..c139fa3 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -752,8 +752,13 @@ class Headline: return times @property - def tags(self): - return list(self.shallow_tags) + self.parent.tags + def tags(self) -> list[str]: + parent_tags = self.parent.tags + if self.doc.environment.get('org-tags-exclude-from-inheritance'): + for tag in self.doc.environment.get('org-tags-exclude-from-inheritance'): + if tag in parent_tags: + parent_tags.remove(tag) + return list(self.shallow_tags) + parent_tags def add_tag(self, tag: str): self.shallow_tags.append(tag) @@ -2234,6 +2239,7 @@ class OrgDoc: ): self.todo_keywords = [HeadlineState(name=kw) for kw in DEFAULT_TODO_KEYWORDS] self.done_keywords = [HeadlineState(name=kw) for kw in DEFAULT_DONE_KEYWORDS] + self.environment = environment keywords_set_in_file = False for keyword in keywords: diff --git a/tests/test_org.py b/tests/test_org.py index d515168..662a472 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -906,6 +906,25 @@ class TestSerde(unittest.TestCase): h1_2_h2 = h1_2.children[0] self.assertEqual(sorted(h1_2_h2.shallow_tags), ['otherh2tag']) + def test_exclude_tags_from_inheritance_property_read_13(self): + with open(os.path.join(DIR, "13-tags.org")) as f: + orig = f.read() + doc = loads(orig, { + 'org-tags-exclude-from-inheritance': ('h1tag', 'otherh2tag') + }) + + self.assertEqual(doc.tags, ['filetag']) + + h1_1, h1_2 = doc.getTopHeadlines() + self.assertEqual(sorted(h1_1.tags), ['filetag', 'h1tag']) + self.assertEqual(sorted(h1_2.tags), ['filetag', 'otherh1tag']) + + h1_1_h2 = h1_1.children[0] + self.assertEqual(sorted(h1_1_h2.tags), ['filetag', 'h2tag']) + + h1_2_h2 = h1_2.children[0] + self.assertEqual(sorted(h1_2_h2.tags), ['filetag', 'otherh1tag', 'otherh2tag']) + def print_tree(tree, indentation=0, headline=None): for element in tree: print(" " * indentation * 2, "EL:", element) From d4b0d0301fb334ebab6caa94b05e2272af13b716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 1 Sep 2024 23:51:10 +0200 Subject: [PATCH 40/55] Test and implement `org-use-tag-inheritance`. --- org_rw/org_rw.py | 9 ++++++++- tests/test_org.py | 22 +++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index c139fa3..b57676f 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -754,7 +754,14 @@ class Headline: @property def tags(self) -> list[str]: parent_tags = self.parent.tags - if self.doc.environment.get('org-tags-exclude-from-inheritance'): + if self.doc.environment.get('org-use-tag-inheritance'): + accepted_tags = [] + for tag in self.doc.environment.get('org-use-tag-inheritance'): + if tag in parent_tags: + accepted_tags.append(tag) + parent_tags = accepted_tags + + elif self.doc.environment.get('org-tags-exclude-from-inheritance'): for tag in self.doc.environment.get('org-tags-exclude-from-inheritance'): if tag in parent_tags: parent_tags.remove(tag) diff --git a/tests/test_org.py b/tests/test_org.py index 662a472..cf370b6 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -910,7 +910,7 @@ class TestSerde(unittest.TestCase): with open(os.path.join(DIR, "13-tags.org")) as f: orig = f.read() doc = loads(orig, { - 'org-tags-exclude-from-inheritance': ('h1tag', 'otherh2tag') + 'org-tags-exclude-from-inheritance': ('h1tag', 'otherh2tag'), }) self.assertEqual(doc.tags, ['filetag']) @@ -925,6 +925,26 @@ class TestSerde(unittest.TestCase): h1_2_h2 = h1_2.children[0] self.assertEqual(sorted(h1_2_h2.tags), ['filetag', 'otherh1tag', 'otherh2tag']) + def test_select_tags_to_inheritance_property_read_13(self): + with open(os.path.join(DIR, "13-tags.org")) as f: + orig = f.read() + doc = loads(orig, { + 'org-tags-exclude-from-inheritance': ('h1tag', 'otherh2tag'), + 'org-use-tag-inheritance': ('h1tag',), + }) + + self.assertEqual(doc.tags, ['filetag']) + + h1_1, h1_2 = doc.getTopHeadlines() + self.assertEqual(sorted(h1_1.tags), ['h1tag']) + self.assertEqual(sorted(h1_2.tags), ['otherh1tag']) + + h1_1_h2 = h1_1.children[0] + self.assertEqual(sorted(h1_1_h2.tags), ['h1tag', 'h2tag']) + + h1_2_h2 = h1_2.children[0] + self.assertEqual(sorted(h1_2_h2.tags), ['otherh2tag']) + def print_tree(tree, indentation=0, headline=None): for element in tree: print(" " * indentation * 2, "EL:", element) From 78bc57e55d94d366286eab5116733869e36a9aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 1 Sep 2024 23:51:38 +0200 Subject: [PATCH 41/55] Fix formatting. --- org_rw/org_rw.py | 10 ++++---- tests/test_org.py | 61 ++++++++++++++++++++++++++--------------------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index b57676f..686525b 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -754,15 +754,15 @@ class Headline: @property def tags(self) -> list[str]: parent_tags = self.parent.tags - if self.doc.environment.get('org-use-tag-inheritance'): + if self.doc.environment.get("org-use-tag-inheritance"): accepted_tags = [] - for tag in self.doc.environment.get('org-use-tag-inheritance'): + for tag in self.doc.environment.get("org-use-tag-inheritance"): if tag in parent_tags: accepted_tags.append(tag) parent_tags = accepted_tags - elif self.doc.environment.get('org-tags-exclude-from-inheritance'): - for tag in self.doc.environment.get('org-tags-exclude-from-inheritance'): + elif self.doc.environment.get("org-tags-exclude-from-inheritance"): + for tag in self.doc.environment.get("org-tags-exclude-from-inheritance"): if tag in parent_tags: parent_tags.remove(tag) return list(self.shallow_tags) + parent_tags @@ -2294,7 +2294,7 @@ class OrgDoc: def tags(self) -> list[str]: for kw in self.keywords: if kw.key == "FILETAGS": - return kw.value.strip(':').split(':') + return kw.value.strip(":").split(":") return [] @property diff --git a/tests/test_org.py b/tests/test_org.py index cf370b6..f27185b 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -877,73 +877,80 @@ class TestSerde(unittest.TestCase): orig = f.read() doc = loads(orig) - self.assertEqual(doc.tags, ['filetag']) + self.assertEqual(doc.tags, ["filetag"]) h1_1, h1_2 = doc.getTopHeadlines() - self.assertEqual(sorted(h1_1.tags), ['filetag', 'h1tag']) - self.assertEqual(sorted(h1_2.tags), ['filetag', 'otherh1tag']) + self.assertEqual(sorted(h1_1.tags), ["filetag", "h1tag"]) + self.assertEqual(sorted(h1_2.tags), ["filetag", "otherh1tag"]) h1_1_h2 = h1_1.children[0] - self.assertEqual(sorted(h1_1_h2.tags), ['filetag', 'h1tag', 'h2tag']) + self.assertEqual(sorted(h1_1_h2.tags), ["filetag", "h1tag", "h2tag"]) h1_2_h2 = h1_2.children[0] - self.assertEqual(sorted(h1_2_h2.tags), ['filetag', 'otherh1tag', 'otherh2tag']) + self.assertEqual(sorted(h1_2_h2.tags), ["filetag", "otherh1tag", "otherh2tag"]) def test_shallow_tag_property_read_13(self): with open(os.path.join(DIR, "13-tags.org")) as f: orig = f.read() doc = loads(orig) - self.assertEqual(doc.shallow_tags, ['filetag']) + self.assertEqual(doc.shallow_tags, ["filetag"]) h1_1, h1_2 = doc.getTopHeadlines() - self.assertEqual(sorted(h1_1.shallow_tags), ['h1tag']) - self.assertEqual(sorted(h1_2.shallow_tags), ['otherh1tag']) + self.assertEqual(sorted(h1_1.shallow_tags), ["h1tag"]) + self.assertEqual(sorted(h1_2.shallow_tags), ["otherh1tag"]) h1_1_h2 = h1_1.children[0] - self.assertEqual(sorted(h1_1_h2.shallow_tags), ['h2tag']) + self.assertEqual(sorted(h1_1_h2.shallow_tags), ["h2tag"]) h1_2_h2 = h1_2.children[0] - self.assertEqual(sorted(h1_2_h2.shallow_tags), ['otherh2tag']) + self.assertEqual(sorted(h1_2_h2.shallow_tags), ["otherh2tag"]) def test_exclude_tags_from_inheritance_property_read_13(self): with open(os.path.join(DIR, "13-tags.org")) as f: orig = f.read() - doc = loads(orig, { - 'org-tags-exclude-from-inheritance': ('h1tag', 'otherh2tag'), - }) + doc = loads( + orig, + { + "org-tags-exclude-from-inheritance": ("h1tag", "otherh2tag"), + }, + ) - self.assertEqual(doc.tags, ['filetag']) + self.assertEqual(doc.tags, ["filetag"]) h1_1, h1_2 = doc.getTopHeadlines() - self.assertEqual(sorted(h1_1.tags), ['filetag', 'h1tag']) - self.assertEqual(sorted(h1_2.tags), ['filetag', 'otherh1tag']) + self.assertEqual(sorted(h1_1.tags), ["filetag", "h1tag"]) + self.assertEqual(sorted(h1_2.tags), ["filetag", "otherh1tag"]) h1_1_h2 = h1_1.children[0] - self.assertEqual(sorted(h1_1_h2.tags), ['filetag', 'h2tag']) + self.assertEqual(sorted(h1_1_h2.tags), ["filetag", "h2tag"]) h1_2_h2 = h1_2.children[0] - self.assertEqual(sorted(h1_2_h2.tags), ['filetag', 'otherh1tag', 'otherh2tag']) + self.assertEqual(sorted(h1_2_h2.tags), ["filetag", "otherh1tag", "otherh2tag"]) def test_select_tags_to_inheritance_property_read_13(self): with open(os.path.join(DIR, "13-tags.org")) as f: orig = f.read() - doc = loads(orig, { - 'org-tags-exclude-from-inheritance': ('h1tag', 'otherh2tag'), - 'org-use-tag-inheritance': ('h1tag',), - }) + doc = loads( + orig, + { + "org-tags-exclude-from-inheritance": ("h1tag", "otherh2tag"), + "org-use-tag-inheritance": ("h1tag",), + }, + ) - self.assertEqual(doc.tags, ['filetag']) + self.assertEqual(doc.tags, ["filetag"]) h1_1, h1_2 = doc.getTopHeadlines() - self.assertEqual(sorted(h1_1.tags), ['h1tag']) - self.assertEqual(sorted(h1_2.tags), ['otherh1tag']) + self.assertEqual(sorted(h1_1.tags), ["h1tag"]) + self.assertEqual(sorted(h1_2.tags), ["otherh1tag"]) h1_1_h2 = h1_1.children[0] - self.assertEqual(sorted(h1_1_h2.tags), ['h1tag', 'h2tag']) + self.assertEqual(sorted(h1_1_h2.tags), ["h1tag", "h2tag"]) h1_2_h2 = h1_2.children[0] - self.assertEqual(sorted(h1_2_h2.tags), ['otherh2tag']) + self.assertEqual(sorted(h1_2_h2.tags), ["otherh2tag"]) + def print_tree(tree, indentation=0, headline=None): for element in tree: From 1dc6eb0b43b8afe05a424c7e4857fb4b0af28b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Mon, 30 Sep 2024 22:59:04 +0200 Subject: [PATCH 42/55] fix: On OrgDoc.get_code_snippets, consider headlines of all levels. --- org_rw/org_rw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 686525b..d7d6ad8 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -2338,7 +2338,7 @@ class OrgDoc: yield hl def get_code_snippets(self): - for headline in self.headlines: + for headline in self.getAllHeadlines(): yield from headline.get_code_snippets() # Writing From 8fe3c27595d16584f72c01f9834378df0b8eec2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Mon, 30 Sep 2024 23:11:21 +0200 Subject: [PATCH 43/55] Read names for code blocks. --- org_rw/org_rw.py | 21 +++++++++++++++++++-- tests/04-code.org | 1 + tests/test_org.py | 3 +++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index d7d6ad8..c5ae4f7 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -882,6 +882,12 @@ class Headline: sections = [] arguments = None + names_by_line = {} + for kw in self.keywords: + if kw.key == "NAME": + names_by_line[kw.linenum] = kw.value + + name = None for delimiter in self.delimiters: if ( delimiter.delimiter_type == DelimiterLineType.BEGIN_BLOCK @@ -890,6 +896,12 @@ class Headline: line_start = delimiter.linenum inside_code = True arguments = delimiter.arguments + + name_line = line_start - 1 + if name_line in names_by_line: + name = names_by_line[name_line] + else: + name = None elif ( delimiter.delimiter_type == DelimiterLineType.END_BLOCK and delimiter.type_data.subtype.lower() == "src" @@ -910,8 +922,10 @@ class Headline: "line_last": end - 1, "content": contents, "arguments": arguments, + "name": name, } ) + name = None arguments = None line_start = None @@ -960,13 +974,16 @@ class Headline: results = [] for section in sections: - name = None content = section["content"] code_result = section.get("result", None) arguments = section.get("arguments", None) + name = section.get("name", None) results.append( CodeSnippet( - name=name, content=content, result=code_result, arguments=arguments + content=content, + result=code_result, + arguments=arguments, + name=name, ) ) diff --git a/tests/04-code.org b/tests/04-code.org index 956d961..7af3aed 100644 --- a/tests/04-code.org +++ b/tests/04-code.org @@ -9,6 +9,7 @@ :CREATED: [2020-01-01 Wed 01:01] :END: +#+NAME: first-code-name #+BEGIN_SRC shell :results verbatim echo "This is a test" echo "with two lines" diff --git a/tests/test_org.py b/tests/test_org.py index f27185b..5a0bc53 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -480,6 +480,7 @@ class TestSerde(unittest.TestCase): snippets = list(doc.get_code_snippets()) self.assertEqual(len(snippets), 3) + self.assertEqual(snippets[0].name, "first-code-name") self.assertEqual( snippets[0].content, 'echo "This is a test"\n' @@ -494,6 +495,7 @@ class TestSerde(unittest.TestCase): "This is a test\n" + "with two lines", ) + self.assertEqual(snippets[1].name, None) self.assertEqual( snippets[1].content, 'echo "This is another test"\n' @@ -504,6 +506,7 @@ class TestSerde(unittest.TestCase): snippets[1].result, "This is another test\n" + "with two lines too" ) + self.assertEqual(snippets[2].name, None) self.assertEqual( snippets[2].content, "/* This code has to be escaped to\n" From 5432c23202f7d5a4f0709e2915ff5629297e1a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Mon, 30 Sep 2024 23:39:43 +0200 Subject: [PATCH 44/55] Explicitly extract code block language. --- org_rw/org_rw.py | 14 +++++++++++++- tests/test_org.py | 5 ++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index c5ae4f7..a466d4f 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -113,7 +113,7 @@ BEGIN_BLOCK_RE = re.compile(r"^\s*#\+BEGIN_(?P[^ ]+)(?P.*)$" END_BLOCK_RE = re.compile(r"^\s*#\+END_(?P[^ ]+)\s*$", re.I) RESULTS_DRAWER_RE = re.compile(r"^\s*:results:\s*$", re.I) CodeSnippet = collections.namedtuple( - "CodeSnippet", ("name", "content", "result", "arguments") + "CodeSnippet", ("name", "content", "result", "language", "arguments") ) # Groupings @@ -916,12 +916,22 @@ class Headline: # the content parsing must be re-thinked contents = contents[:-1] + language = None + if arguments is not None: + arguments = arguments.strip() + if " " in arguments: + language = arguments[: arguments.index(" ")] + arguments = arguments[arguments.index(" ") + 1 :] + else: + language = arguments + arguments = None sections.append( { "line_first": start + 1, "line_last": end - 1, "content": contents, "arguments": arguments, + "language": language, "name": name, } ) @@ -977,12 +987,14 @@ class Headline: content = section["content"] code_result = section.get("result", None) arguments = section.get("arguments", None) + language = section.get("language", None) name = section.get("name", None) results.append( CodeSnippet( content=content, result=code_result, arguments=arguments, + language=language, name=name, ) ) diff --git a/tests/test_org.py b/tests/test_org.py index 5a0bc53..ad35b89 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -481,6 +481,7 @@ class TestSerde(unittest.TestCase): snippets = list(doc.get_code_snippets()) self.assertEqual(len(snippets), 3) self.assertEqual(snippets[0].name, "first-code-name") + self.assertEqual(snippets[0].language, "shell") self.assertEqual( snippets[0].content, 'echo "This is a test"\n' @@ -488,7 +489,7 @@ class TestSerde(unittest.TestCase): + "exit 0 # Exit successfully", ) self.assertEqual( - snippets[0].arguments.split(), ["shell", ":results", "verbatim"] + snippets[0].arguments.split(), [":results", "verbatim"] ) self.assertEqual( snippets[0].result, @@ -496,6 +497,7 @@ class TestSerde(unittest.TestCase): ) self.assertEqual(snippets[1].name, None) + self.assertEqual(snippets[1].language, "shell") self.assertEqual( snippets[1].content, 'echo "This is another test"\n' @@ -507,6 +509,7 @@ class TestSerde(unittest.TestCase): ) self.assertEqual(snippets[2].name, None) + self.assertEqual(snippets[2].language, "c") self.assertEqual( snippets[2].content, "/* This code has to be escaped to\n" From d4b40e404dc7f637e6d46cd1c3884eb29c49211c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sat, 5 Oct 2024 10:08:41 +0200 Subject: [PATCH 45/55] Apply autoformatter. --- tests/test_org.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_org.py b/tests/test_org.py index ad35b89..f6b6be4 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -488,9 +488,7 @@ class TestSerde(unittest.TestCase): + 'echo "with two lines"\n' + "exit 0 # Exit successfully", ) - self.assertEqual( - snippets[0].arguments.split(), [":results", "verbatim"] - ) + self.assertEqual(snippets[0].arguments.split(), [":results", "verbatim"]) self.assertEqual( snippets[0].result, "This is a test\n" + "with two lines", From 691ce30a68c51e3cd57e21d07b0bc0137048c043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Mon, 7 Oct 2024 23:22:44 +0200 Subject: [PATCH 46/55] Simplify state setting, update `.is_todo`/`.is_done` props. --- org_rw/org_rw.py | 42 ++++++++++++++++++++++++++--- tests/test_org.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index a466d4f..31b904c 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -337,7 +337,7 @@ class Headline: self.priority = priority self.title_start = title_start self.title = parse_content_block([RawLine(linenum=start_line, line=title)]) - self.state = state + self._state = state self.tags_start = tags_start self.shallow_tags = tags self.contents = contents @@ -726,6 +726,42 @@ class Headline: def id(self, value): self.set_property("ID", value) + @property + def state(self) -> HeadlineState: + return self._state + + @state.setter + def state(self, new_state: Union[None, str, HeadlineState]) -> None: + """ + Update the state of a Headline. If the state is a known one it will update it's TODO/DONE properties. + + Args: + new_state (str|HeadlineState): New state, either it's literal value or it's structure. + """ + if new_state is None: + self.is_todo = False + self.is_done = False + # TODO: Check & log if appropriate? + self._state = None + return + + if isinstance(new_state, str): + new_state = HeadlineState(name=new_state) + + state_name = new_state["name"] + if state_name in [kw["name"] for kw in self.doc.todo_keywords]: + self.is_todo = True + self.is_done = False + # TODO: Check & log if appropriate? + elif state_name in [kw["name"] for kw in self.doc.done_keywords]: + self.is_todo = False + self.is_done = True + # TODO: Check, log & if appropriate? + else: + # TODO: Should we raise a warning, raise an exception, update the is_todo/is_done? + pass + self._state = new_state + @property def clock(self): times = [] @@ -2378,8 +2414,8 @@ class OrgDoc: tags = ":" + ":".join(headline.shallow_tags) + ":" state = "" - if headline.state: - state = headline.state["name"] + " " + if headline._state: + state = headline._state["name"] + " " raw_title = token_list_to_raw(headline.title.contents) tags_padding = "" diff --git a/tests/test_org.py b/tests/test_org.py index f6b6be4..a1fdff1 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -955,6 +955,75 @@ class TestSerde(unittest.TestCase): h1_2_h2 = h1_2.children[0] self.assertEqual(sorted(h1_2_h2.tags), ["otherh2tag"]) + def test_update_headline_from_none_to_todo(self): + orig = "* First entry" + doc = loads(orig) + self.assertEqual(doc.headlines[0].is_todo, False) + self.assertEqual(doc.headlines[0].is_done, False) + self.assertEqual(doc.headlines[0].state, None) + + doc.headlines[0].state = "TODO" + self.assertEqual(doc.headlines[0].is_todo, True) + self.assertEqual(doc.headlines[0].is_done, False) + self.assertEqual(doc.headlines[0].state["name"], "TODO") + + self.assertEqual(dumps(doc), "* TODO First entry") + + def test_update_headline_from_none_to_done(self): + orig = "* First entry" + doc = loads(orig) + self.assertEqual(doc.headlines[0].is_todo, False) + self.assertEqual(doc.headlines[0].is_done, False) + self.assertEqual(doc.headlines[0].state, None) + + doc.headlines[0].state = org_rw.HeadlineState(name="DONE") + self.assertEqual(doc.headlines[0].is_todo, False) + self.assertEqual(doc.headlines[0].is_done, True) + self.assertEqual(doc.headlines[0].state["name"], "DONE") + + self.assertEqual(dumps(doc), "* DONE First entry") + + def test_update_headline_from_todo_to_none(self): + orig = "* TODO First entry" + doc = loads(orig) + self.assertEqual(doc.headlines[0].is_todo, True) + self.assertEqual(doc.headlines[0].is_done, False) + self.assertEqual(doc.headlines[0].state["name"], "TODO") + + doc.headlines[0].state = None + self.assertEqual(doc.headlines[0].is_todo, False) + self.assertEqual(doc.headlines[0].is_done, False) + self.assertEqual(doc.headlines[0].state, None) + + self.assertEqual(dumps(doc), "* First entry") + + def test_update_headline_from_todo_to_done(self): + orig = "* TODO First entry" + doc = loads(orig) + self.assertEqual(doc.headlines[0].is_todo, True) + self.assertEqual(doc.headlines[0].is_done, False) + self.assertEqual(doc.headlines[0].state["name"], "TODO") + + doc.headlines[0].state = "DONE" + self.assertEqual(doc.headlines[0].is_todo, False) + self.assertEqual(doc.headlines[0].is_done, True) + self.assertEqual(doc.headlines[0].state["name"], "DONE") + self.assertEqual(dumps(doc), "* DONE First entry") + + def test_update_headline_from_done_to_todo(self): + orig = "* DONE First entry" + doc = loads(orig) + self.assertEqual(doc.headlines[0].is_todo, False) + self.assertEqual(doc.headlines[0].is_done, True) + self.assertEqual(doc.headlines[0].state["name"], "DONE") + + doc.headlines[0].state = org_rw.HeadlineState(name="TODO") + self.assertEqual(doc.headlines[0].is_todo, True) + self.assertEqual(doc.headlines[0].is_done, False) + self.assertEqual(doc.headlines[0].state["name"], "TODO") + + self.assertEqual(dumps(doc), "* TODO First entry") + def print_tree(tree, indentation=0, headline=None): for element in tree: From 9c04717a1239182acda2af1de516a6e25d3dd079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 9 Feb 2025 13:49:09 +0100 Subject: [PATCH 47/55] Fix support of code blocks outside headlines. --- org_rw/org_rw.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 31b904c..ed1cf2c 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -2307,6 +2307,7 @@ class OrgDoc: list_items, structural, properties, + delimiters, environment=BASE_ENVIRONMENT, ): self.todo_keywords = [HeadlineState(name=kw) for kw in DEFAULT_TODO_KEYWORDS] @@ -2336,6 +2337,7 @@ class OrgDoc: self.list_items: List[ListItem] = list_items self.structural: List = structural self.properties: List = properties + self.delimiters: List = delimiters self._path = None self.headlines: List[Headline] = list( map(lambda hl: parse_headline(hl, self, self), headlines) @@ -2500,6 +2502,9 @@ class OrgDoc: for struct in self.structural: lines.append(dump_structural(struct)) + for content in self.delimiters: + lines.append(dump_delimiters(content)) + for kw in self.keywords: lines.append(dump_kw(kw)) @@ -2537,6 +2542,7 @@ class OrgDocReader: self.list_items, self.structural, self.properties, + self.delimiters, self.environment, ) From c0fc78fe331df6e5859020fbbfc61e16410ffbc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 9 Feb 2025 14:11:32 +0100 Subject: [PATCH 48/55] fix(gitea): Fix build with newer images. --- .gitea/workflows/tests.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/tests.yaml b/.gitea/workflows/tests.yaml index f56a490..a3adf0a 100644 --- a/.gitea/workflows/tests.yaml +++ b/.gitea/workflows/tests.yaml @@ -9,8 +9,8 @@ jobs: - name: Check out repository code uses: actions/checkout@v3 - run: apt-get update && apt-get install -y python3-pip - - run: pip install -e . - - run: pip install pytest + - run: pip install --break-system-package -e . + - run: pip install --break-system-package pytest - run: pytest mypy: @@ -19,8 +19,8 @@ jobs: - name: Check out repository code uses: actions/checkout@v3 - run: apt-get update && apt-get install -y python3-pip - - run: pip install -e . - - run: pip install mypy + - run: pip install --break-system-package -e . + - run: pip install --break-system-package mypy - run: mypy org_rw --check-untyped-defs style-formatting: @@ -29,8 +29,8 @@ jobs: - name: Check out repository code uses: actions/checkout@v3 - run: apt-get update && apt-get install -y python3-pip - - run: pip install -e . - - run: pip install black + - run: pip install --break-system-package -e . + - run: pip install --break-system-package black - run: black --check . style-sorted-imports: @@ -39,8 +39,8 @@ jobs: - name: Check out repository code uses: actions/checkout@v3 - run: apt-get update && apt-get install -y python3-pip - - run: pip install -e . - - run: pip install isort + - run: pip install --break-system-package -e . + - run: pip install --break-system-package isort - run: isort --profile black --check . stability-extra-test: @@ -49,5 +49,5 @@ jobs: - name: Check out repository code uses: actions/checkout@v3 - run: apt-get update && apt-get install -y git-core python3-pip - - run: pip install -e . + - run: pip install --break-system-package -e . - run: bash extra-tests/check_all.sh From dbac8b2d6e13942881bf55e07d42560274adec7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 9 Feb 2025 14:11:52 +0100 Subject: [PATCH 49/55] feat(dom): Add support for generic drawer outputs. --- org_rw/dom.py | 8 ++++++++ org_rw/org_rw.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/org_rw/dom.py b/org_rw/dom.py index f9ed40f..0b779b5 100644 --- a/org_rw/dom.py +++ b/org_rw/dom.py @@ -24,6 +24,14 @@ class ResultsDrawerNode(DrawerNode): return "".format(len(self.children)) +class GenericDrawerNode(DrawerNode): + def __init__(self, drawer_name): + self.drawer_name = drawer_name + + def __repr__(self): + return "".format(self.drawer_name, len(self.children)) + + class PropertyNode: def __init__(self, key, value): self.key = key diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index ed1cf2c..2208f3c 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -122,6 +122,7 @@ NON_FINISHED_GROUPS = ( dom.ListGroupNode, dom.ResultsDrawerNode, dom.PropertyDrawerNode, + dom.GenericDrawerNode, ) FREE_GROUPS = (dom.CodeBlock,) @@ -636,6 +637,13 @@ class Headline: assert current_node is None current_node = dom.ResultsDrawerNode() + # TODO: Allow indentation of these blocks inside others + indentation_tree = [current_node] + tree.append(current_node) + elif content.strip().startswith(':') and content.strip().endswith(':'): + assert current_node is None + current_node = dom.GenericDrawerNode(content.strip().strip(':')) + # TODO: Allow indentation of these blocks inside others indentation_tree = [current_node] tree.append(current_node) From 8b4e12ea2eb8ff754792492454be7a48fb2c1ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 9 Feb 2025 16:25:39 +0100 Subject: [PATCH 50/55] Add `dom.TableRow.get_raw()` support. --- org_rw/dom.py | 7 +++++++ org_rw/utils.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/org_rw/dom.py b/org_rw/dom.py index 0b779b5..61e0882 100644 --- a/org_rw/dom.py +++ b/org_rw/dom.py @@ -70,12 +70,19 @@ class TableSeparatorRow: def __init__(self, orig=None): self.orig = orig + def get_raw(self): + return get_raw_contents(self.orig) + class TableRow: def __init__(self, cells, orig=None): self.cells = cells self.orig = orig + def get_raw(self): + return get_raw_contents(self.orig) + + class Text: def __init__(self, content): diff --git a/org_rw/utils.py b/org_rw/utils.py index 5b8b4e5..146a942 100644 --- a/org_rw/utils.py +++ b/org_rw/utils.py @@ -7,6 +7,7 @@ from .org_rw import ( Italic, Line, ListItem, + TableRow, RawLine, Strike, Text, @@ -50,6 +51,8 @@ def get_raw_contents(doc) -> str: return doc.get_raw() if isinstance(doc, ListItem): return dump_contents(doc)[1] + if isinstance(doc, TableRow): + return dump_contents(doc)[1] print("Unhandled type: " + str(doc)) raise NotImplementedError("Unhandled type: " + str(doc)) From 0bdb29a2783a76898dd709b6c23ce25fbe3a498c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 9 Feb 2025 16:49:06 +0100 Subject: [PATCH 51/55] Don't cut delimiter lines out of `get_lines_between()`. --- org_rw/org_rw.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 2208f3c..8839342 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -872,9 +872,24 @@ class Headline: yield from get_links_from_content(item.content) def get_lines_between(self, start, end): - for line in self.contents: + # @TODO: Generalize for other line types too. + everything = ( + [] + # + self.keywords + + self.contents + # + self.list_items + # + self.table_rows + # + self.properties + # + self.structural + + self.delimiters + ) + + for line in everything: if start <= line.linenum < end: - yield "".join(line.get_raw()) + if 'get_raw' in dir(line): + yield "".join(line.get_raw()) + else: + yield line.line def get_contents(self, format): if format == "raw": From 506a17dc5cc233d7f971db1a0765747e724610bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 9 Feb 2025 16:49:25 +0100 Subject: [PATCH 52/55] fix(org_rw): Ensure closing delimiters are same subtype as openers. --- org_rw/org_rw.py | 1 + 1 file changed, 1 insertion(+) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 8839342..f26ef94 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -415,6 +415,7 @@ class Headline: if ( isinstance(line, DelimiterLine) and line.delimiter_type == DelimiterLineType.END_BLOCK + and line.type_data.subtype == current_node.header.type_data.subtype ): start = current_node.header.linenum From 3b90723250dd7bde121eafbc5ed2bdf8d9dc125b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Sun, 9 Feb 2025 16:50:22 +0100 Subject: [PATCH 53/55] format: Automatic formatting fixes. --- org_rw/dom.py | 1 - org_rw/org_rw.py | 6 +++--- org_rw/utils.py | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/org_rw/dom.py b/org_rw/dom.py index 61e0882..baf0092 100644 --- a/org_rw/dom.py +++ b/org_rw/dom.py @@ -83,7 +83,6 @@ class TableRow: return get_raw_contents(self.orig) - class Text: def __init__(self, content): self.content = content diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index f26ef94..6baadd1 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -641,9 +641,9 @@ class Headline: # TODO: Allow indentation of these blocks inside others indentation_tree = [current_node] tree.append(current_node) - elif content.strip().startswith(':') and content.strip().endswith(':'): + elif content.strip().startswith(":") and content.strip().endswith(":"): assert current_node is None - current_node = dom.GenericDrawerNode(content.strip().strip(':')) + current_node = dom.GenericDrawerNode(content.strip().strip(":")) # TODO: Allow indentation of these blocks inside others indentation_tree = [current_node] @@ -887,7 +887,7 @@ class Headline: for line in everything: if start <= line.linenum < end: - if 'get_raw' in dir(line): + if "get_raw" in dir(line): yield "".join(line.get_raw()) else: yield line.line diff --git a/org_rw/utils.py b/org_rw/utils.py index 146a942..87f6712 100644 --- a/org_rw/utils.py +++ b/org_rw/utils.py @@ -7,9 +7,9 @@ from .org_rw import ( Italic, Line, ListItem, - TableRow, RawLine, Strike, + TableRow, Text, Underlined, Verbatim, From f936bccf7f668d356bdf1cee596d94a6a2e567a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Wed, 16 Apr 2025 00:46:52 +0200 Subject: [PATCH 54/55] doc: Add a small "Principles" section to README. --- README.org | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.org b/README.org index 95ec98a..253c8f6 100644 --- a/README.org +++ b/README.org @@ -7,6 +7,10 @@ A python library to parse, modify and save Org-mode files. - Modify these data and write it back to disk. - Keep the original structure intact (indentation, spaces, format, ...). +** Principles +- Data structures should be exposed as it's read on Emacs's org-mode or when in doubt as raw as possible. +- Data in the objects should be modificable, as a way to update the document itself. *Consider this a Object-oriented design.* +- *Modification of the original text if there's no change is considered a bug (see [[id:7363ba38-1662-4d3c-9e83-0999824975b7][Known issues]]).* ** Safety mechanism As this library is still in early development. Running it over files might produce unexpected changes on them. For this reason it's heavily recommended to @@ -21,6 +25,9 @@ Also, see [[id:76e77f7f-c9e0-4c83-ad2f-39a5a8894a83][Known issues:Structure modi not properly stored and can trigger this safety mechanism on a false-positive. * Known issues +:PROPERTIES: +:ID: 7363ba38-1662-4d3c-9e83-0999824975b7 +:END: ** Structure modifications :PROPERTIES: :ID: 76e77f7f-c9e0-4c83-ad2f-39a5a8894a83 From 55fc87cfdcef23eea402148c0a237976988107cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Wed, 16 Apr 2025 01:00:09 +0200 Subject: [PATCH 55/55] Add absence of dependencies as principle. --- README.org | 6 ++++-- requirements.txt | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) delete mode 100644 requirements.txt diff --git a/README.org b/README.org index 253c8f6..6f03720 100644 --- a/README.org +++ b/README.org @@ -8,9 +8,11 @@ A python library to parse, modify and save Org-mode files. - Keep the original structure intact (indentation, spaces, format, ...). ** Principles -- Data structures should be exposed as it's read on Emacs's org-mode or when in doubt as raw as possible. -- Data in the objects should be modificable, as a way to update the document itself. *Consider this a Object-oriented design.* +- Avoid any dependency outside of Python's standard library. +- Don't do anything outside of the scope of parsing/re-serializing Org-mode files. - *Modification of the original text if there's no change is considered a bug (see [[id:7363ba38-1662-4d3c-9e83-0999824975b7][Known issues]]).* +- Data structures should be exposed as it's read on Emacs's org-mode or when in doubt as raw as possible. +- Data in the objects should be modificable as a way to update the document itself. *Consider this a Object-oriented design.* ** Safety mechanism As this library is still in early development. Running it over files might produce unexpected changes on them. For this reason it's heavily recommended to diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 1c51c66..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -# No external requirements at this point