Compare commits

...

35 Commits

Author SHA1 Message Date
8280949f23 Merge pull request 'feat: Simplify headline state setting.' (#12) from feat/cleaner-state-setting into develop
All checks were successful
Testing / pytest (push) Successful in 32s
Testing / mypy (push) Successful in 33s
Testing / style-formatting (push) Successful in 36s
Testing / style-sorted-imports (push) Successful in 25s
Testing / stability-extra-test (push) Successful in 29s
Reviewed-on: kenkeiras/org-rw#12
2024-10-07 21:26:19 +00:00
Sergio Martínez Portela
691ce30a68 Simplify state setting, update .is_todo/.is_done props.
All checks were successful
Testing / pytest (push) Successful in 28s
Testing / mypy (push) Successful in 35s
Testing / style-formatting (push) Successful in 30s
Testing / style-sorted-imports (push) Successful in 24s
Testing / stability-extra-test (push) Successful in 32s
2024-10-07 23:23:15 +02:00
48de06abc7 Merge pull request 'feat: Name code snippets' (#11) from feat/named-code-snippets into develop
All checks were successful
Testing / pytest (push) Successful in 28s
Testing / mypy (push) Successful in 37s
Testing / style-formatting (push) Successful in 33s
Testing / style-sorted-imports (push) Successful in 24s
Testing / stability-extra-test (push) Successful in 26s
Reviewed-on: kenkeiras/org-rw#11
2024-10-06 22:26:00 +00:00
Sergio Martínez Portela
d4b40e404d Apply autoformatter.
All checks were successful
Testing / pytest (push) Successful in 28s
Testing / mypy (push) Successful in 37s
Testing / style-formatting (push) Successful in 33s
Testing / style-sorted-imports (push) Successful in 25s
Testing / stability-extra-test (push) Successful in 30s
2024-10-05 10:08:41 +02:00
Sergio Martínez Portela
5432c23202 Explicitly extract code block language.
Some checks failed
Testing / pytest (push) Successful in 26s
Testing / mypy (push) Successful in 35s
Testing / style-formatting (push) Failing after 30s
Testing / style-sorted-imports (push) Successful in 35s
Testing / stability-extra-test (push) Successful in 28s
2024-09-30 23:55:07 +02:00
Sergio Martínez Portela
8fe3c27595 Read names for code blocks. 2024-09-30 23:39:37 +02:00
Sergio Martínez Portela
1dc6eb0b43 fix: On OrgDoc.get_code_snippets, consider headlines of all levels. 2024-09-30 22:59:04 +02:00
5019b44dd5 Merge pull request 'Feat: Complete tags property' (#10) from feat/consider-file-tags-on-headlines into develop
All checks were successful
Testing / pytest (push) Successful in 28s
Testing / mypy (push) Successful in 36s
Testing / style-formatting (push) Successful in 28s
Testing / style-sorted-imports (push) Successful in 32s
Testing / stability-extra-test (push) Successful in 35s
Reviewed-on: kenkeiras/org-rw#10
2024-09-03 18:33:03 +00:00
Sergio Martínez Portela
78bc57e55d Fix formatting.
All checks were successful
Testing / pytest (push) Successful in 27s
Testing / mypy (push) Successful in 30s
Testing / style-formatting (push) Successful in 28s
Testing / style-sorted-imports (push) Successful in 22s
Testing / stability-extra-test (push) Successful in 36s
2024-09-01 23:51:38 +02:00
Sergio Martínez Portela
d4b0d0301f Test and implement org-use-tag-inheritance.
Some checks failed
Testing / pytest (push) Has been cancelled
Testing / mypy (push) Has been cancelled
Testing / style-formatting (push) Has been cancelled
Testing / style-sorted-imports (push) Has been cancelled
Testing / stability-extra-test (push) Has been cancelled
2024-09-01 23:51:10 +02:00
Sergio Martínez Portela
92078617fc Add tests and implement org-tags-exclude-from-inheritance.
Some checks failed
Testing / pytest (push) Successful in 25s
Testing / mypy (push) Successful in 31s
Testing / style-formatting (push) Failing after 31s
Testing / style-sorted-imports (push) Successful in 30s
Testing / stability-extra-test (push) Successful in 27s
2024-09-01 23:46:10 +02:00
Sergio Martínez Portela
852f472374 Implement OrgDoc .shallow_tags .
Some checks failed
Testing / pytest (push) Successful in 27s
Testing / mypy (push) Successful in 32s
Testing / style-formatting (push) Failing after 42s
Testing / style-sorted-imports (push) Successful in 28s
Testing / stability-extra-test (push) Successful in 28s
2024-09-01 23:37:26 +02:00
Sergio Martínez Portela
570e6bb764 Implement OrgDoc .tags. 2024-09-01 23:35:33 +02:00
Sergio Martínez Portela
e0306bf3a5 Add (failing) test for tags property read.
Some checks failed
Testing / pytest (push) Failing after 26s
Testing / mypy (push) Successful in 37s
Testing / style-formatting (push) Failing after 31s
Testing / style-sorted-imports (push) Successful in 25s
Testing / stability-extra-test (push) Has been cancelled
2024-09-01 23:35:23 +02:00
bfe60271eb Merge pull request 'Fix text parsing issues' (#9) from fix/require-whitespace-for-list-item-tag-separator into develop
All checks were successful
Testing / pytest (push) Successful in 33s
Testing / mypy (push) Successful in 32s
Testing / style-formatting (push) Successful in 26s
Testing / style-sorted-imports (push) Successful in 32s
Testing / stability-extra-test (push) Successful in 33s
Reviewed-on: kenkeiras/org-rw#9
2024-09-01 12:10:25 +00:00
Sergio Martínez Portela
4af4cda44b Fix formatting.
All checks were successful
Testing / pytest (push) Successful in 31s
Testing / mypy (push) Successful in 41s
Testing / style-formatting (push) Successful in 43s
Testing / style-sorted-imports (push) Successful in 28s
Testing / stability-extra-test (push) Successful in 41s
2024-08-22 00:26:11 +02:00
Sergio Martínez Portela
5552b3324b Handle ] which not close link descriptions or references.
Some checks failed
Testing / stability-extra-test (push) Waiting to run
Testing / pytest (push) Successful in 38s
Testing / style-sorted-imports (push) Waiting to run
Testing / mypy (push) Successful in 46s
Testing / style-formatting (push) Has been cancelled
2024-08-22 00:21:02 +02:00
Sergio Martínez Portela
f31c64c242 Properly track which tokens are used for closing formats. 2024-08-22 00:20:54 +02:00
Sergio Martínez Portela
490b36887a Require space before list item tag separator. 2024-08-22 00:20:15 +02:00
05718e1001 Merge pull request 'feat: Apply and check autoformatting' (#8) from apply-and-check-autoformatting into develop
All checks were successful
Testing / pytest (push) Successful in 43s
Testing / mypy (push) Successful in 54s
Testing / style-formatting (push) Successful in 55s
Testing / style-sorted-imports (push) Successful in 28s
Testing / stability-extra-test (push) Successful in 53s
Reviewed-on: kenkeiras/org-rw#8
2024-08-19 21:41:32 +00:00
Sergio Martínez Portela
e991074346 fix: Apply import sorting.
All checks were successful
Testing / pytest (push) Successful in 30s
Testing / mypy (push) Successful in 34s
Testing / style-formatting (push) Successful in 29s
Testing / style-sorted-imports (push) Successful in 26s
Testing / stability-extra-test (push) Successful in 32s
2024-08-18 22:49:33 +02:00
Sergio Martínez Portela
66b42e0b96 feat: Add script to apply formatting tools. 2024-08-18 22:49:06 +02:00
Sergio Martínez Portela
c6d8575ae5 test: Test sorted imports. 2024-08-18 22:47:42 +02:00
Sergio Martínez Portela
8ca480ad77 fix: Apply black formatter. 2024-08-18 22:47:24 +02:00
Sergio Martínez Portela
0a55c64551 test: Add formatting check to CI/CD. 2024-08-18 22:47:24 +02:00
991781a249 Merge pull request 'feat: enhance type annotations and formatting' (#7) from lyz/org-rw:feat/small-improvements into develop
All checks were successful
Testing / pytest (push) Successful in 37s
Testing / mypy (push) Successful in 34s
Testing / stability-extra-test (push) Successful in 51s
Reviewed-on: kenkeiras/org-rw#7
Reviewed-by: kenkeiras <kenkeiras@codigoparallevar.com>
2024-08-18 20:16:16 +00:00
Lyz
75055f5e08
feat: enhance type annotations and formatting
feat: Added `py.typed` file to indicate the presence of type information in the package.

Mypy needs this
2024-08-02 20:08:04 +02:00
40d58d5488 Merge pull request 'Add TODO keywords programmatically' (#2) from feat/add-todo-keywords-programmatically into develop
All checks were successful
Testing / pytest (push) Successful in 25s
Testing / mypy (push) Successful in 32s
Testing / stability-extra-test (push) Successful in 24s
Reviewed-on: kenkeiras/org-rw#2
Reviewed-by: lyz <lyz@riseup.net>
2024-07-29 14:34:18 +00:00
Sergio Martínez Portela
09f9030818 tests: fix typings to match mypy expectations.
All checks were successful
Testing / pytest (push) Successful in 25s
Testing / mypy (push) Successful in 31s
Testing / stability-extra-test (push) Successful in 21s
2024-07-29 15:31:39 +01:00
Sergio Martínez Portela
7e44bce145 Merge remote-tracking branch 'origin/develop' into feat/add-todo-keywords-programmatically
Some checks failed
Testing / pytest (push) Successful in 26s
Testing / mypy (push) Failing after 30s
Testing / stability-extra-test (push) Successful in 23s
2024-07-29 15:19:58 +01:00
ef893a72a0 Merge pull request 'feat/improvements' (#1) from lyz/org-rw:feat/improvements into develop
Some checks failed
Testing / pytest (push) Successful in 30s
Testing / mypy (push) Failing after 34s
Testing / stability-extra-test (push) Successful in 27s
Reviewed-on: kenkeiras/org-rw#1
Reviewed-by: kenkeiras <kenkeiras@codigoparallevar.com>
2024-07-29 14:17:16 +00:00
Sergio Martínez Portela
b174405c90 Refactor headline state parsing.
Some checks failed
Testing / pytest (push) Successful in 25s
Testing / mypy (push) Failing after 33s
Testing / stability-extra-test (push) Successful in 22s
- 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.
2024-07-20 18:10:05 +02:00
Sergio Martínez Portela
da2d8c8c6d Add org-todo-keywords environment to programatically set states.
Some checks failed
Testing / pytest (push) Successful in 41s
Testing / mypy (push) Failing after 32s
Testing / stability-extra-test (push) Successful in 25s
2024-07-20 14:42:41 +02:00
Sergio Martínez Portela
4c169f5d47 Add (passing) test to read TODO/DONE states from file. 2024-07-20 14:42:20 +02:00
Sergio Martínez Portela
f4d63c2f93 Add (failing) test. 2024-07-20 14:41:09 +02:00
11 changed files with 575 additions and 87 deletions

View File

@ -23,6 +23,26 @@ 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 .
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:

View File

@ -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 "<List: {}>".format(len(self.children))
class TableNode:
def __init__(self):
self.children = []
@ -56,21 +57,24 @@ class TableNode:
def __repr__(self):
return "<Table: {}>".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,7 +109,9 @@ class CodeBlock(BlockNode):
def __repr__(self):
return "<Code: {}>".format(len(self.lines or []))
DomNode = Union[DrawerNode,
DomNode = Union[
DrawerNode,
PropertyNode,
ListGroupNode,
TableNode,
@ -114,12 +120,13 @@ DomNode = Union[DrawerNode,
Text,
ListItem,
BlockNode,
]
]
ContainerDomNode = Union[DrawerNode,
ContainerDomNode = Union[
DrawerNode,
ListGroupNode,
TableNode,
BlockNode,
]
]
from .utils import get_raw_contents

View File

@ -1,6 +1,5 @@
from __future__ import annotations
from typing import Optional
from datetime import timedelta
import collections
import difflib
import logging
@ -9,17 +8,32 @@ import re
import sys
from datetime import date, datetime, timedelta
from enum import Enum
from typing import cast, Iterator, List, Optional, Tuple, 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
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:",
@ -53,9 +67,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<stars>\*+)(?P<spacing>\s+)(?P<line>.*?)$")
KEYWORDS_RE = re.compile(
@ -92,7 +103,7 @@ PLANNING_RE = re.compile(
r")+\s*"
)
LIST_ITEM_RE = re.compile(
r"(?P<indentation>\s*)((?P<bullet>[*\-+])|((?P<counter>\d|[a-zA-Z])(?P<counter_sep>[.)]))) ((?P<checkbox_indentation>\s*)\[(?P<checkbox_value>[ Xx])\])?((?P<tag_indentation>\s*)(?P<tag>.*?)::)?(?P<content>.*)"
r"(?P<indentation>\s*)((?P<bullet>[*\-+])|((?P<counter>\d|[a-zA-Z])(?P<counter_sep>[.)]))) ((?P<checkbox_indentation>\s*)\[(?P<checkbox_value>[ Xx])\])?((?P<tag_indentation>\s*)((?P<tag>.*?)\s::))?(?P<content>.*)"
)
IMPLICIT_LINK_RE = re.compile(r"(https?:[^<> ]*[a-zA-Z0-9])")
@ -102,7 +113,7 @@ BEGIN_BLOCK_RE = re.compile(r"^\s*#\+BEGIN_(?P<subtype>[^ ]+)(?P<arguments>.*)$"
END_BLOCK_RE = re.compile(r"^\s*#\+END_(?P<subtype>[^ ]+)\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
@ -115,6 +126,17 @@ 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]
class NonReproducibleDocument(Exception):
"""
Exception thrown when a document would be saved as different contents
@ -315,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
@ -354,12 +376,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)
@ -704,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 = []
@ -730,11 +788,20 @@ class Headline:
return times
@property
def tags(self):
if isinstance(self.parent, OrgDoc):
return list(self.shallow_tags)
else:
return list(self.shallow_tags) + self.parent.tags
def tags(self) -> list[str]:
parent_tags = self.parent.tags
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)
return list(self.shallow_tags) + parent_tags
def add_tag(self, tag: str):
self.shallow_tags.append(tag)
@ -851,6 +918,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
@ -859,6 +932,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"
@ -873,14 +952,26 @@ 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,
}
)
name = None
arguments = None
line_start = None
@ -929,13 +1020,18 @@ class Headline:
results = []
for section in sections:
name = None
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(
name=name, content=content, result=code_result, arguments=arguments
content=content,
result=code_result,
arguments=arguments,
language=language,
name=name,
)
)
@ -1084,7 +1180,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)
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)
@ -1476,17 +1574,31 @@ 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:
"""
@ -1517,7 +1629,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
@ -1643,7 +1755,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):
@ -1873,7 +1985,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:
if (
char == "]"
and len(contents) > i + 1
and in_link
and contents[i + 1] in "]["
):
if contents[i + 1] == "]":
cut_string()
@ -1924,6 +2041,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)
@ -1966,7 +2084,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)
@ -2014,7 +2132,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 ""
)
@ -2052,16 +2170,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
@ -2158,21 +2276,60 @@ 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
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]
self.environment = environment
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
states = parse_todo_done_keywords(keyword.value)
self.todo_keywords, self.done_keywords = (
states["not_completed"],
states["completed"],
)
keywords_set_in_file = True
self.todo_keywords = re.sub(r"\s{2,}", " ", todo_kws.strip()).split()
self.done_keywords = re.sub(r"\s{2,}", " ", done_kws.strip()).split()
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"],
)
self.keywords: List[Property] = keywords
self.contents: List[RawLine] = contents
@ -2198,6 +2355,17 @@ 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 []
@property
def shallow_tags(self) -> list[str]:
return self.tags
## Querying
def get_links(self):
for headline in self.headlines:
@ -2235,7 +2403,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
@ -2246,8 +2414,8 @@ class OrgDoc:
tags = ":" + ":".join(headline.shallow_tags) + ":"
state = ""
if headline.state:
state = headline.state + " "
if headline._state:
state = headline._state["name"] + " "
raw_title = token_list_to_raw(headline.title.contents)
tags_padding = ""
@ -2348,7 +2516,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]] = []
@ -2359,8 +2527,9 @@ class OrgDocReader:
self.structural: List = []
self.properties: List = []
self.current_drawer: Optional[List] = None
self.environment = environment
def finalize(self):
def finalize(self) -> OrgDoc:
return OrgDoc(
self.headlines,
self.keywords,
@ -2368,6 +2537,7 @@ class OrgDocReader:
self.list_items,
self.structural,
self.properties,
self.environment,
)
## Construction
@ -2575,7 +2745,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)
@ -2665,9 +2835,28 @@ class OrgDocReader:
raise
def loads(s, environment=BASE_ENVIRONMENT, extra_cautious=True):
reader = OrgDocReader()
reader.read(s, environment)
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()
if extra_cautious: # Check that all options can be properly re-serialized
after_dump = dumps(doc)
@ -2705,20 +2894,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

0
org_rw/py.typed Normal file
View File

View File

@ -1,9 +1,19 @@
import uuid
from .org_rw import (Bold, Code, Headline, Italic, Line, RawLine, ListItem, Strike, Text,
Underlined, Verbatim)
from .org_rw import dump_contents
from .org_rw import (
Bold,
Code,
Headline,
Italic,
Line,
ListItem,
RawLine,
Strike,
Text,
Underlined,
Verbatim,
dump_contents,
)
def get_hl_raw_contents(doc: Headline) -> str:

11
scripts/apply-formatting.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/sh
set -eu
cd "`dirname $0`"
cd ..
set -x
isort --profile black .
black .

View File

@ -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"

13
tests/13-tags.org Normal file
View File

@ -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:

View File

@ -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__))
@ -480,20 +480,22 @@ 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'
+ 'echo "with two lines"\n'
+ "exit 0 # Exit successfully",
)
self.assertEqual(
snippets[0].arguments.split(), ["shell", ":results", "verbatim"]
)
self.assertEqual(snippets[0].arguments.split(), [":results", "verbatim"])
self.assertEqual(
snippets[0].result,
"This is a test\n" + "with two lines",
)
self.assertEqual(snippets[1].name, None)
self.assertEqual(snippets[1].language, "shell")
self.assertEqual(
snippets[1].content,
'echo "This is another test"\n'
@ -504,6 +506,8 @@ 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].language, "c")
self.assertEqual(
snippets[2].content,
"/* This code has to be escaped to\n"
@ -833,6 +837,193 @@ 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 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"}
)
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_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 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 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 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 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:

View File

@ -1,7 +1,9 @@
"""Test the Timestamp object."""
import pytest
from datetime import date, datetime
import pytest
from org_rw import Timestamp

View File

@ -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):