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 01/35] 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 02/35] 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 03/35] 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 04/35] 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 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 05/35] 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 06/35] 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 07/35] 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 08/35] 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 09/35] 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 10/35] 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 11/35] 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 12/35] 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 13/35] 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 14/35] 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 15/35] 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 16/35] 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 17/35] 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 18/35] 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 19/35] 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 20/35] 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 21/35] 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 22/35] 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 23/35] 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 24/35] 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 25/35] 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 26/35] 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 27/35] 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 28/35] 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 29/35] 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 30/35] 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 31/35] 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 32/35] 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 33/35] 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 34/35] 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 35/35] 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