diff --git a/.gitignore b/.gitignore index 2fafd0e..5c8ee49 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,3 @@ dmypy.json # Cython debug symbols cython_debug/ - -# Files for PyPI publishing -README.md diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 9b25ed9..c0a1244 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1,7 +1,7 @@ from __future__ import annotations -from typing import Optional -from datetime import timedelta + import collections +from ctypes import ArgumentError import difflib import logging import os @@ -9,21 +9,16 @@ 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 typing import cast, Iterator, List, Literal, Optional, Tuple, Union from .types import HeadlineDict 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:", @@ -57,6 +52,9 @@ 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( @@ -96,41 +94,24 @@ LIST_ITEM_RE = re.compile( r"(?P\s*)((?P[*\-+])|((?P\d|[a-zA-Z])(?P[.)]))) ((?P\s*)\[(?P[ Xx])\])?((?P\s*)(?P.*?)::)?(?P.*)" ) -IMPLICIT_LINK_RE = re.compile(r"(https?:[^<> ]*[a-zA-Z0-9])") +IMPLICIT_LINK_RE = re.compile(r'(https?:[^<> ]*[a-zA-Z0-9])') # Org-Babel BEGIN_BLOCK_RE = re.compile(r"^\s*#\+BEGIN_(?P[^ ]+)(?P.*)$", re.I) END_BLOCK_RE = re.compile(r"^\s*#\+END_(?P[^ ]+)\s*$", re.I) RESULTS_DRAWER_RE = re.compile(r"^\s*:results:\s*$", re.I) -CodeSnippet = collections.namedtuple( - "CodeSnippet", ("name", "content", "result", "arguments") -) +CodeSnippet = collections.namedtuple("CodeSnippet", ("name", "content", "result", "arguments")) # Groupings -NON_FINISHED_GROUPS = ( - type(None), - dom.ListGroupNode, - dom.ResultsDrawerNode, - dom.PropertyDrawerNode, -) +NON_FINISHED_GROUPS = (type(None), dom.ListGroupNode, dom.ResultsDrawerNode, dom.PropertyDrawerNode) FREE_GROUPS = (dom.CodeBlock,) -# 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 from what it's loaded from. """ - pass @@ -183,19 +164,20 @@ def unescape_block_lines(block: str) -> str: Remove leading ',' from block_lines if they escape `*` characters. """ i = 0 - lines = block.split("\n") + lines = block.split('\n') while i < len(lines): line = lines[i] - if line.lstrip(" ").startswith(",") and line.lstrip(" ,").startswith("*"): + if (line.lstrip(' ').startswith(',') + and line.lstrip(' ,').startswith('*') + ): # Remove leading ',' - lead_pos = line.index(",") - line = line[:lead_pos] + line[lead_pos + 1 :] + lead_pos = line.index(',') + line = line[:lead_pos] + line[lead_pos + 1:] lines[i] = line i += 1 - return "\n".join(lines) - + return '\n'.join(lines) def get_links_from_content(content): in_link = False @@ -229,8 +211,11 @@ def get_links_from_content(content): elif isinstance(tok, str): implicit_links = IMPLICIT_LINK_RE.findall(tok) for link in implicit_links: - yield Link(cast(str, link), cast(str, link), None) - + yield Link( + cast(str, link), + cast(str, link), + None + ) def text_to_dom(tokens, item): if tokens is None: @@ -252,13 +237,11 @@ def text_to_dom(tokens, item): in_description = True elif tok.tok_type == LinkTokenType.CLOSE: rng = RangeInRaw(item, open_link_token, tok) - contents.append( - Link( - "".join(link_value), - "".join(link_description) if in_description else None, - rng, - ) - ) + contents.append(Link( + "".join(link_value), + "".join(link_description) if in_description else None, + rng, + )) in_link = False in_description = False link_value = [] @@ -273,7 +256,6 @@ def text_to_dom(tokens, item): return contents - def get_line(item): if isinstance(item, Text): return item.linenum @@ -309,12 +291,9 @@ class Headline: list_items, table_rows, parent, - is_todo: bool, - is_done: bool, + is_todo, + is_done, spacing, - scheduled: Optional[Time] = None, - deadline: Optional[Time] = None, - closed: Optional[Time] = None, ): self.start_line = start_line self.depth = depth @@ -324,7 +303,9 @@ class Headline: self.priority_start = priority_start self.priority = priority self.title_start = title_start - self.title = parse_content_block([RawLine(linenum=start_line, line=title)]) + self.title = parse_content_block( + [RawLine(linenum=start_line, line=title)] + ) self.state = state self.tags_start = tags_start self.shallow_tags = tags @@ -337,9 +318,9 @@ class Headline: self.parent = parent self.is_todo = is_todo self.is_done = is_done - self.scheduled = scheduled - self.deadline = deadline - self.closed = closed + self.scheduled = None + self.deadline = None + self.closed = None self.spacing = spacing # Read planning line @@ -364,12 +345,12 @@ class Headline: ) ] - 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) + 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) # Remove from contents self._remove_element_in_line(start_line + 1) @@ -381,6 +362,7 @@ class Headline: par = par.parent return par + def as_dom(self): everything = ( self.keywords @@ -418,7 +400,7 @@ class Headline: tree.append(current_node) current_node = None else: - pass # Ignore + pass # Ignore elif isinstance(line, Property): if type(current_node) in NON_FINISHED_GROUPS: @@ -431,24 +413,22 @@ class Headline: tree_up = list(indentation_tree) while len(tree_up) > 0: node: dom.DomNode = tree_up[-1] - if isinstance(node, dom.BlockNode) or isinstance( - node, dom.DrawerNode + if (isinstance(node, dom.BlockNode) + or isinstance(node, dom.DrawerNode) ): node.append(dom.Text(line)) current_node = node contents = None break - elif (not isinstance(node, dom.TableNode)) and ( - type(node) not in NON_FINISHED_GROUPS + elif ((not isinstance(node, dom.TableNode)) and + (type(node) not in NON_FINISHED_GROUPS) ): - raise NotImplementedError( - "Not implemented node type: {} (headline_id={}, line={}, doc={})".format( - node, - self.id, - line.linenum, - self.doc.path, - ) - ) + raise NotImplementedError('Not implemented node type: {} (headline_id={}, line={}, doc={})'.format( + node, + self.id, + line.linenum, + self.doc.path, + )) else: tree_up.pop(-1) else: @@ -458,8 +438,7 @@ class Headline: indentation_tree = tree_up elif isinstance(line, ListItem): - if ( - current_node is None + if (current_node is None or isinstance(current_node, dom.TableNode) or isinstance(current_node, dom.BlockNode) or isinstance(current_node, dom.DrawerNode) @@ -473,14 +452,7 @@ class Headline: indentation_tree.append(current_node) if not isinstance(current_node, dom.ListGroupNode): if not isinstance(current_node, dom.ListGroupNode): - raise Exception( - "Expected a {}, found: {} on line {} on {}".format( - dom.ListGroupNode, - current_node, - line.linenum, - self.doc.path, - ) - ) + raise Exception("Expected a {}, found: {} on line {} on {}".format(dom.ListGroupNode, current_node, line.linenum, self.doc.path)) # This can happen. Frequently inside a LogDrawer if len(indentation_tree) > 0 and ( @@ -506,9 +478,10 @@ class Headline: if isinstance(c, dom.ListItem) ] - if len(list_children) == 0: + if (len(list_children) == 0): break - if len(list_children[-1].orig.indentation) <= len(line.indentation): + if ((len(list_children[-1].orig.indentation) + <= len(line.indentation))): # No more breaking out of lists, it's indentation # is less than ours break @@ -521,11 +494,7 @@ class Headline: else: current_node = indentation_tree[-1] - node = dom.ListItem( - text_to_dom(line.tag, line), - text_to_dom(line.content, line), - orig=line, - ) + node = dom.ListItem(text_to_dom(line.tag, line), text_to_dom(line.content, line), orig=line) current_node.append(node) elif isinstance(line, TableRow): @@ -542,18 +511,10 @@ class Headline: list_node.append(current_node) indentation_tree.append(current_node) else: - logging.debug( - "Expected a {}, found: {} on line {}".format( - dom.TableNode, current_node, line.linenum - ) - ) + logging.debug("Expected a {}, found: {} on line {}".format(dom.TableNode, current_node, line.linenum)) # This can happen. Frequently inside a LogDrawer - if ( - len(line.cells) > 0 - and len(line.cells[0]) > 0 - and line.cells[0][0] == "-" - ): + if len(line.cells) > 0 and len(line.cells[0]) > 0 and line.cells[0][0] == '-': node = dom.TableSeparatorRow(orig=line) else: node = dom.TableRow(line.cells, orig=line) @@ -565,9 +526,7 @@ class Headline: and line.delimiter_type == DelimiterLineType.BEGIN_BLOCK ): assert type(current_node) in NON_FINISHED_GROUPS - current_node = dom.CodeBlock( - line, line.type_data.subtype, line.arguments - ) + current_node = dom.CodeBlock(line, line.type_data.subtype, line.arguments) elif isinstance(line, Keyword): logging.warning("Keywords not implemented on `as_dom()`") @@ -601,7 +560,7 @@ class Headline: indentation_tree = [current_node] elif content.strip().upper() == ":END:": if current_node is None and len(indentation_tree) == 0: - logging.error("Finished node (:END:) with no known starter") + logging.error('Finished node (:END:) with no known starter') else: tree_up = list(indentation_tree) while len(tree_up) > 0: @@ -614,11 +573,7 @@ class Headline: else: tree_up.pop(-1) else: - raise Exception( - "Unexpected node ({}) on headline (id={}), line {}".format( - current_node, self.id, linenum - ) - ) + raise Exception('Unexpected node ({}) on headline (id={}), line {}'.format(current_node, self.id, linenum)) current_node = None elif content.strip().upper() == ":RESULTS:": assert current_node is None @@ -643,22 +598,19 @@ class Headline: lists.append([li]) else: num_lines = li.linenum - (last_line + 1) - lines_between = "".join( - [ - "\n" + l - for l in self.get_lines_between(last_line + 1, li.linenum) - ] - ) + lines_between = ''.join(['\n' + l + for l in self.get_lines_between(last_line + 1, li.linenum)] + ) # Only empty lines - if (num_lines == lines_between.count("\n")) and ( - len(lines_between.strip()) == 0 + if ((num_lines == lines_between.count('\n')) + and (len(lines_between.strip()) == 0) ): lists[-1].append(li) else: lists.append([li]) - last_line = li.linenum + sum(c.count("\n") for c in li.content) + last_line = li.linenum + sum(c.count('\n') for c in li.content) return lists # @DEPRECATED: use `get_lists` @@ -725,7 +677,7 @@ class Headline: time_seg = content[len("CLOCK:") :].strip() - parsed: Optional[Time] = None + parsed: Union[None, OrgTime, TimeRange] = None if "--" in time_seg: # TODO: Consider duration start, end = time_seg.split("=")[0].split("--") @@ -803,7 +755,7 @@ class Headline: for lst in self.get_lists(): for item in lst: if item.tag: - yield from get_links_from_content(item.tag) + yield from get_links_from_content(item.tag) yield from get_links_from_content(item.content) def get_lines_between(self, start, end): @@ -825,7 +777,7 @@ class Headline: if linenum == line.linenum: return line - for s_lnum, struc in self.structural: + for (s_lnum, struc) in self.structural: if linenum == s_lnum: return ("structural", struc) @@ -851,7 +803,7 @@ class Headline: ) def get_structural_end_after(self, linenum): - for s_lnum, struc in self.structural: + for (s_lnum, struc) in self.structural: if s_lnum > linenum and struc.strip().upper() == ":END:": return (s_lnum, struc) @@ -862,17 +814,11 @@ class Headline: arguments = None for delimiter in self.delimiters: - if ( - delimiter.delimiter_type == DelimiterLineType.BEGIN_BLOCK - and delimiter.type_data.subtype.lower() == "src" - ): + if delimiter.delimiter_type == DelimiterLineType.BEGIN_BLOCK and delimiter.type_data.subtype.lower() == "src": line_start = delimiter.linenum inside_code = True arguments = delimiter.arguments - elif ( - delimiter.delimiter_type == DelimiterLineType.END_BLOCK - and delimiter.type_data.subtype.lower() == "src" - ): + elif delimiter.delimiter_type == DelimiterLineType.END_BLOCK and delimiter.type_data.subtype.lower() == "src": inside_code = False start, end = line_start, delimiter.linenum @@ -943,11 +889,7 @@ class Headline: content = section["content"] code_result = section.get("result", None) arguments = section.get("arguments", None) - results.append( - CodeSnippet( - name=name, content=content, result=code_result, arguments=arguments - ) - ) + results.append(CodeSnippet(name=name, content=content, result=code_result, arguments=arguments)) return results @@ -989,20 +931,13 @@ Property = collections.namedtuple( "Property", ("linenum", "match", "key", "value", "options") ) - class ListItem: - def __init__( - self, - linenum, - match, + def __init__(self, + linenum, match, indentation, - bullet, - counter, - counter_sep, - checkbox_indentation, - checkbox_value, - tag_indentation, - tag, + bullet, counter, counter_sep, + checkbox_indentation, checkbox_value, + tag_indentation, tag, content, ): self.linenum = linenum @@ -1019,11 +954,10 @@ class ListItem: @property def text_start_pos(self): - return len(self.indentation) + 1 # Indentation + bullet + return len(self.indentation) + 1 # Indentation + bullet def append_line(self, line): - self.content += parse_content_block("\n" + line).contents - + self.content += parse_content_block('\n' + line).contents TableRow = collections.namedtuple( "TableRow", @@ -1036,102 +970,26 @@ TableRow = collections.namedtuple( ), ) - # @TODO How are [YYYY-MM-DD HH:mm--HH:mm] and ([... HH:mm]--[... HH:mm]) differentiated ? # @TODO Consider recurrence annotations class Timestamp: - def __init__( - self, - active: bool = True, - year: Optional[int] = None, - month: Optional[int] = None, - day: Optional[int] = None, - dow: Optional[str] = None, - hour: Optional[int] = None, - minute: Optional[int] = None, - repetition: Optional[str] = None, - datetime_: Optional[Union[date, datetime]] = None, - ): - """ - Initializes a Timestamp instance. - - Args: - active (bool): Whether the timestamp is active. - year (Optional[int]): The year of the timestamp. - month (Optional[int]): The month of the timestamp. - day (Optional[int]): The day of the timestamp. - dow (Optional[str]): The day of the week, if any. - hour (Optional[int]): The hour of the timestamp, if any. - minute (Optional[int]): The minute of the timestamp, if any. - repetition (Optional[str]): The repetition pattern, if any. - datetime_ (Optional[Union[date, datetime]]): A date or datetime object. - - Raises: - ValueError: If neither datetime_ nor the combination of year, month, and day are provided. - """ + def __init__(self, active, year, month, day, dow, hour, minute, repetition=None): self.active = active - - if datetime_ is not None: - self.from_datetime(datetime_) - elif year is not None and month is not None and day is not None: - self._year = year - self._month = month - self._day = day - self.dow = dow - self.hour = hour - self.minute = minute - else: - raise ValueError( - "Either datetime_ or year, month, and day must be provided." - ) + self._year = year + self._month = month + self._day = day + self.dow = dow + self.hour = hour + self.minute = minute self.repetition = repetition def to_datetime(self) -> datetime: - """ - Converts the Timestamp to a datetime object. - - Returns: - datetime: The corresponding datetime object. - """ if self.hour is not None: - return datetime(self.year, self.month, self.day, self.hour, self.minute or 0) + return datetime(self.year, self.month, self.day, self.hour, self.minute) else: return datetime(self.year, self.month, self.day, 0, 0) - def from_datetime(self, dt: Union[datetime, date]) -> None: - """ - Updates the current Timestamp instance based on a datetime or date object. - - Args: - dt (Union[datetime, date]): The datetime or date object to use for updating the instance. - """ - if isinstance(dt, datetime): - self._year = dt.year - self._month = dt.month - self._day = dt.day - self.hour = dt.hour - self.minute = dt.minute - elif isinstance(dt, date): - self._year = dt.year - self._month = dt.month - self._day = dt.day - self.hour = None - self.minute = None - else: - raise TypeError("Expected datetime or date object") - - self.dow = None # Day of the week can be set to None - - def __add__(self, delta: timedelta) -> "Timestamp": - """ - Adds a timedelta to the Timestamp. - - Args: - delta (timedelta): The time difference to add. - - Returns: - Timestamp: The resulting Timestamp instance. - """ + def __add__(self, delta: timedelta): as_dt = self.to_datetime() to_dt = as_dt + delta @@ -1142,102 +1000,64 @@ class Timestamp: day=to_dt.day, dow=None, hour=to_dt.hour if self.hour is not None or to_dt.hour != 0 else None, - minute=( - to_dt.minute if self.minute is not None or to_dt.minute != 0 else None - ), + minute=to_dt.minute + if self.minute is not None or to_dt.minute != 0 + else None, repetition=self.repetition, ) - def __eq__(self, other: object) -> bool: - """ - Checks if two Timestamp instances are equal. - - Args: - other (object): The other object to compare with. - - Returns: - bool: True if the instances are equal, False otherwise. - """ + def __eq__(self, other): if not isinstance(other, Timestamp): return False return ( - self.active == other.active - and self.year == other.year - and self.month == other.month - and self.day == other.day - and self.dow == other.dow - and self.hour == other.hour - and self.minute == other.minute - and self.repetition == other.repetition + (self.active == other.active) + and (self.year == other.year) + and (self.month == other.month) + and (self.day == other.day) + and (self.dow == other.dow) + and (self.hour == other.hour) + and (self.minute == other.minute) + and (self.repetition == other.repetition) ) - def __lt__(self, other: object) -> bool: - """ - Checks if the Timestamp is less than another Timestamp. - - Args: - other (object): The other object to compare with. - - Returns: - bool: True if this Timestamp is less than the other, False otherwise. - """ + def __lt__(self, other): if not isinstance(other, Timestamp): return False return self.to_datetime() < other.to_datetime() - def __gt__(self, other: object) -> bool: - """ - Checks if the Timestamp is greater than another Timestamp. - - Args: - other (object): The other object to compare with. - - Returns: - bool: True if this Timestamp is greater than the other, False otherwise. - """ + def __gt__(self, other): if not isinstance(other, Timestamp): return False return self.to_datetime() > other.to_datetime() - def __repr__(self) -> str: - """ - Returns a string representation of the Timestamp. - - Returns: - str: The string representation of the Timestamp. - """ + def __repr__(self): return timestamp_to_string(self) + # Properties whose modification changes the Day-Of-Week @property - def year(self) -> int: - """Returns the year of the timestamp.""" + def year(self): return self._year @year.setter - def year(self, value: int) -> None: - """Sets the year of the timestamp and resets the day of the week.""" + def year(self, value): self._year = value self.dow = None @property - def month(self) -> int: - """Returns the month of the timestamp.""" + def month(self): return self._month @month.setter - def month(self, value: int) -> None: - """Sets the month of the timestamp and resets the day of the week.""" + def month(self, value): self._month = value self.dow = None @property - def day(self) -> int: - """Returns the day of the timestamp.""" + def day(self): return self._day @day.setter - def day(self, value: int) -> None: - """Sets the day of the timestamp and resets the day of the week.""" + def day(self, value): self._day = value self.dow = None @@ -1247,7 +1067,9 @@ class DelimiterLineType(Enum): END_BLOCK = 2 -BlockDelimiterTypeData = collections.namedtuple("BlockDelimiterTypeData", ("subtype")) +BlockDelimiterTypeData = collections.namedtuple( + "BlockDelimiterTypeData", ("subtype") +) DelimiterLine = collections.namedtuple( "DelimiterLine", ("linenum", "line", "delimiter_type", "type_data", "arguments") @@ -1297,276 +1119,30 @@ def token_from_type(tok_type): class TimeRange: - """Represents a range of time with a start and end time. - - Attributes: - start_time (OrgTime): The start time of the range. - end_time (OrgTime): The end time of the range. - """ - - def __init__(self, start_time: OrgTime, end_time: OrgTime) -> None: - """Initializes a TimeRange with a start time and an end time. - - Args: - start_time (OrgTime): The start time of the range. - end_time (OrgTime): The end time of the range. - - Raises: - AssertionError: If start_time or end_time is None. - """ - if start_time is None or end_time is None: - raise ValueError("start_time and end_time must not be None.") + def __init__(self, start_time: OrgTime, end_time: OrgTime): + assert start_time is not None + assert end_time is not None self.start_time = start_time self.end_time = end_time def to_raw(self) -> str: - """Converts the TimeRange to its raw string representation. - - Returns: - str: The raw string representation of the TimeRange. - """ return timerange_to_string(self) @property def duration(self) -> timedelta: - """Calculates the duration of the TimeRange. - - Returns: - timedelta: The duration between start_time and end_time. - """ delta = self.end - self.start return delta @property def start(self) -> datetime: - """Gets the start time as a datetime object. - - Returns: - datetime: The start time of the TimeRange. - """ return self.start_time.time.to_datetime() @property def end(self) -> datetime: - """Gets the end time as a datetime object. - - Returns: - datetime: The end time of the TimeRange. - """ return self.end_time.time.to_datetime() - def activate(self) -> None: - """ - Sets the active state for the times. - """ - self.start_time.active = True - self.end_time.active = True - def deactivate(self) -> None: - """ - Sets the inactive state for the times. - """ - self.start_time.active = False - self.end_time.active = False - - -class OrgTime: - """Represents a point in time with optional end time and repetition. - - Attributes: - time (Timestamp): The start time of the OrgTime instance. - end_time (Optional[Timestamp]): The end time of the OrgTime instance, if any. - """ - - def __init__(self, ts: Timestamp, end_time: Optional[Timestamp] = None) -> None: - """Initializes an OrgTime with a start time and an optional end time. - - Args: - ts (Timestamp): The start time of the OrgTime instance. - end_time (Optional[Timestamp], optional): The end time of the OrgTime instance. Defaults to None. - - Raises: - ValueError: If ts is None. - """ - if ts is None: - raise ValueError("Timestamp (ts) must not be None.") - self.time = ts - self.end_time = end_time - - @property - def repetition(self) -> Optional[str]: - """Gets the repetition information from the start time. - - Returns: - Optional[str]: The repetition information, or None if not present. - """ - return self.time.repetition - - @property - def duration(self) -> timedelta: - """Calculates the duration between the start and end times. - - Returns: - timedelta: The duration between the start and end times. If no end time is present, returns zero timedelta. - """ - if self.end_time is None: - return timedelta() # No duration - return self.end_time.to_datetime() - self.time.to_datetime() - - def to_raw(self) -> str: - """Converts the OrgTime to its raw string representation. - - Returns: - str: The raw string representation of the OrgTime. - """ - return timestamp_to_string(self.time, self.end_time) - - def __repr__(self) -> str: - """Provides a string representation of the OrgTime instance. - - Returns: - str: The string representation of the OrgTime. - """ - return f"OrgTime({self.to_raw()})" - - @classmethod - def parse(cls, value: str) -> Optional["OrgTime"]: - """Parses a string into an OrgTime object. - - Args: - value (str): The string representation of the OrgTime. - - Returns: - Optional[OrgTime]: The parsed OrgTime instance, or None if parsing fails. - """ - if m := ACTIVE_TIME_STAMP_RE.match(value): - active = True - elif m := INACTIVE_TIME_STAMP_RE.match(value): - active = False - else: - return None - - repetition = None - if m.group("repetition"): - repetition = m.group("repetition").strip() - - if m.group("end_hour"): - return cls( - Timestamp( - active, - int(m.group("year")), - int(m.group("month")), - int(m.group("day")), - m.group("dow"), - int(m.group("start_hour")), - int(m.group("start_minute")), - repetition=repetition, - ), - Timestamp( - active, - int(m.group("year")), - int(m.group("month")), - int(m.group("day")), - m.group("dow"), - int(m.group("end_hour")), - int(m.group("end_minute")), - ), - ) - - return cls( - Timestamp( - active, - int(m.group("year")), - int(m.group("month")), - int(m.group("day")), - m.group("dow"), - int(m.group("start_hour")) if m.group("start_hour") else None, - int(m.group("start_minute")) if m.group("start_minute") else None, - repetition=repetition, - ) - ) - - @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.active = True - - def deactivate(self) -> None: - """ - Sets the inactive state for the timestamp. - """ - self.active = False - - def from_datetime(self, dt: datetime) -> None: - """ - Updates the timestamp to use the given datetime. - - Args: - dt (datetime): The datetime to update the timestamp with. - """ - self.time.from_datetime(dt) - if self.end_time: - self.end_time.from_datetime(dt) - - -def time_from_str(s: str) -> Optional[OrgTime]: - return OrgTime.parse(s) - - -def timerange_to_string(tr: TimeRange): - return tr.start_time.to_raw() + "--" + tr.end_time.to_raw() - - -def timestamp_to_string(ts: Timestamp, end_time: Optional[Timestamp] = None) -> str: - date = "{year}-{month:02d}-{day:02d}".format( - year=ts.year, month=ts.month, day=ts.day - ) - if ts.dow: - date = date + " " + ts.dow - - if ts.hour is not None: - base = "{date} {hour:02}:{minute:02d}".format( - date=date, hour=ts.hour, minute=ts.minute or 0 - ) - else: - base = date - - if end_time is not None: - assert end_time.hour is not None - assert end_time.minute is not None - base = "{base}-{hour:02}:{minute:02d}".format( - base=base, hour=end_time.hour, minute=end_time.minute - ) - - if ts.repetition is not None: - base = base + " " + ts.repetition - - if ts.active: - return "<{}>".format(base) - else: - return "[{}]".format(base) - - -Time = Union[TimeRange, OrgTime] - - -def parse_time(value: str) -> Optional[Time]: +def parse_time(value: str) -> Union[None, TimeRange, OrgTime]: if (value.count(">--<") == 1) or (value.count("]--[") == 1): # Time ranges with two different dates # @TODO properly consider "=> DURATION" section @@ -1596,6 +1172,118 @@ def parse_org_time_range(start, end) -> Optional[TimeRange]: return TimeRange(start_time, end_time) +class OrgTime: + def __init__(self, ts: Timestamp, end_time: Optional[Timestamp] = None): + assert ts is not None + self.time = ts + self.end_time = end_time + + @property + def repetition(self): + return self.time.repetition + + @property + def duration(self): + if self.end_time is None: + return timedelta() # No duration + else: + return self.end_time.to_datetime() - self.time.to_datetime() + + def to_raw(self): + return timestamp_to_string(self.time, self.end_time) + + def __repr__(self): + return f"OrgTime({self.to_raw()})" + + @classmethod + def parse(self, value: str) -> Optional[OrgTime]: + if m := ACTIVE_TIME_STAMP_RE.match(value): + active = True + elif m := INACTIVE_TIME_STAMP_RE.match(value): + active = False + else: + # raise ArgumentError("Cannot parse `{}` as OrgTime".format(value)) + return None + + repetition = None + if m.group("repetition"): + repetition = m.group("repetition").strip() + + if m.group("end_hour"): + return OrgTime( + Timestamp( + active, + int(m.group("year")), + int(m.group("month")), + int(m.group("day")), + m.group("dow"), + int(m.group("start_hour")), + int(m.group("start_minute")), + repetition=repetition, + ), + Timestamp( + active, + int(m.group("year")), + int(m.group("month")), + int(m.group("day")), + m.group("dow"), + int(m.group("end_hour")), + int(m.group("end_minute")), + ), + ) + + return OrgTime( + Timestamp( + active, + int(m.group("year")), + int(m.group("month")), + int(m.group("day")), + m.group("dow"), + int(m.group("start_hour")) if m.group("start_hour") else None, + int(m.group("start_minute")) if m.group("start_minute") else None, + repetition=repetition, + ) + ) + + +def time_from_str(s: str) -> Optional[OrgTime]: + return OrgTime.parse(s) + + +def timerange_to_string(tr: TimeRange): + return tr.start_time.to_raw() + "--" + tr.end_time.to_raw() + + +def timestamp_to_string(ts: Timestamp, end_time: Optional[Timestamp] = None) -> str: + date = "{year}-{month:02d}-{day:02d}".format( + year=ts.year, month=ts.month, day=ts.day + ) + if ts.dow: + date = date + " " + ts.dow + + if ts.hour is not None: + base = "{date} {hour:02}:{minute:02d}".format( + date=date, hour=ts.hour, minute=ts.minute + ) + else: + base = date + + if end_time is not None: + assert end_time.hour is not None + assert end_time.minute is not None + base = "{base}-{hour:02}:{minute:02d}".format( + base=base, hour=end_time.hour, minute=end_time.minute + ) + + if ts.repetition is not None: + base = base + " " + ts.repetition + + if ts.active: + return "<{}>".format(base) + else: + return "[{}]".format(base) + + def get_raw(doc): if isinstance(doc, str): return doc @@ -1619,9 +1307,7 @@ class Line: class Link: - def __init__( - self, value: str, description: Optional[str], origin: Optional[RangeInRaw] - ): + def __init__(self, value: str, description: Optional[str], origin: Optional[RangeInRaw]): self._value = value self._description = description self._origin = origin @@ -1638,8 +1324,7 @@ class Link: if self._description: new_contents.append(LinkToken(LinkTokenType.OPEN_DESCRIPTION)) new_contents.append(self._description) - if self._origin is not None: - self._origin.update_range(new_contents) + self._origin.update_range(new_contents) @property def value(self): @@ -1674,7 +1359,6 @@ class Text: def get_raw(self): return token_list_to_raw(self.contents) - def token_list_to_plaintext(tok_list) -> str: contents = [] in_link = False @@ -1699,7 +1383,7 @@ def token_list_to_plaintext(tok_list) -> str: if not in_description: # This might happen when link doesn't have a separate description link_description = link_url - contents.append("".join(link_description)) + contents.append(''.join(link_description)) in_link = False in_description = False @@ -1710,7 +1394,6 @@ def token_list_to_plaintext(tok_list) -> str: return "".join(contents) - def token_list_to_raw(tok_list): contents = [] for chunk in tok_list: @@ -1838,11 +1521,13 @@ TOKEN_TYPE_OPEN_LINK = 3 TOKEN_TYPE_CLOSE_LINK = 4 TOKEN_TYPE_OPEN_DESCRIPTION = 5 -TokenItems = Union[Tuple[int, Union[None, str, MarkerToken]],] +TokenItems = Union[ + Tuple[int, Union[None, str, MarkerToken]], +] def tokenize_contents(contents: str) -> List[TokenItems]: - tokens: List[TokenItems] = [] + tokens: List[TokenItems] = [] last_char = None text: List[str] = [] @@ -1991,7 +1676,7 @@ def parse_contents(raw_contents: List[RawLine]): return [parse_content_block(block) for block in blocks] -def parse_content_block(raw_contents: Union[List[RawLine], str]): +def parse_content_block(raw_contents: Union[List[RawLine],str]): contents_buff = [] if isinstance(raw_contents, str): contents_buff.append(raw_contents) @@ -2008,7 +1693,7 @@ def parse_content_block(raw_contents: Union[List[RawLine], str]): contents: List[Union[str, MarkerToken, LinkToken]] = [] # Use tokens to tag chunks of text with it's container type - for tok_type, tok_val in tokens: + for (tok_type, tok_val) in tokens: if tok_type == TOKEN_TYPE_TEXT: assert isinstance(tok_val, str) contents.append(tok_val) @@ -2035,21 +1720,17 @@ def dump_contents(raw): elif isinstance(raw, ListItem): bullet = raw.bullet if raw.bullet else raw.counter + raw.counter_sep content_full = token_list_to_raw(raw.content) - content_lines = content_full.split("\n") - content = "\n".join(content_lines) + content_lines = content_full.split('\n') + content = '\n'.join(content_lines) checkbox = f"[{raw.checkbox_value}]" if raw.checkbox_value else "" - tag = ( - f"{raw.tag_indentation}{token_list_to_raw(raw.tag or '')}::" - if raw.tag or raw.tag_indentation - else "" - ) + tag = f"{raw.tag_indentation}{token_list_to_raw(raw.tag or '')}::" if raw.tag or raw.tag_indentation else "" return ( raw.linenum, f"{raw.indentation}{bullet} {checkbox}{tag}{content}", ) elif isinstance(raw, TableRow): - closed = "|" if raw.last_cell_closed else "" + closed = '|' if raw.last_cell_closed else '' return ( raw.linenum, f"{' ' * raw.indentation}|{'|'.join(raw.cells)}{closed}{raw.suffix}", @@ -2077,25 +1758,23 @@ 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 + " "): hl_state = state - title = title[len(state['name'] + " ") :] + title = title[len(state + " ") :] is_todo = True break else: for state in doc.done_keywords or []: - if title.startswith(state['name'] + " "): + if title.startswith(state + " "): hl_state = state - title = title[len(state['name'] + " ") :] + title = title[len(state + " ") :] is_done = True break contents = parse_contents(hl["contents"]) if not (isinstance(parent, OrgDoc) or depth > parent.depth): - raise AssertionError( - "Incorrectly parsed parent on `{}' > `{}'".format(parent.title, title) - ) + raise AssertionError("Incorrectly parsed parent on `{}' > `{}'".format(parent.title, title)) headline = Headline( start_line=hl["linenum"], @@ -2183,53 +1862,19 @@ 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, headlines, keywords, contents, list_items, structural, properties ): - 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.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"): - states = parse_todo_done_keywords(keyword.value) - self.todo_keywords, self.done_keywords = states['not_completed'], states['completed'] - keywords_set_in_file = True + todo_kws, done_kws = re.sub(r"\([^)]+\)", "", keyword.value).split("|", 1) - 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.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 @@ -2247,7 +1892,7 @@ class OrgDoc: Created by org-roam v2. """ for p in self.properties: - if p.key == "ID": + if p.key == 'ID': return p.value return None @@ -2304,7 +1949,7 @@ class OrgDoc: state = "" if headline.state: - state = headline.state['name'] + " " + state = headline.state + " " raw_title = token_list_to_raw(headline.title.contents) tags_padding = "" @@ -2405,7 +2050,7 @@ class OrgDoc: class OrgDocReader: - def __init__(self, environment=BASE_ENVIRONMENT): + def __init__(self): self.headlines: List[HeadlineDict] = [] self.keywords: List[Keyword] = [] self.headline_hierarchy: List[Optional[HeadlineDict]] = [] @@ -2416,7 +2061,6 @@ class OrgDocReader: self.structural: List = [] self.properties: List = [] self.current_drawer: Optional[List] = None - self.environment = environment def finalize(self): return OrgDoc( @@ -2426,7 +2070,6 @@ class OrgDocReader: self.list_items, self.structural, self.properties, - self.environment, ) ## Construction @@ -2469,14 +2112,9 @@ class OrgDocReader: self.headline_hierarchy.append(headline) if all([hl is not None for hl in self.headline_hierarchy]): - if not ( - [ - len(cast(HeadlineDict, hl)["orig"].group("stars")) - for hl in self.headline_hierarchy - ] - == list(range(1, len(self.headline_hierarchy) + 1)) - ): - raise AssertionError("Error on Headline Hierarchy") + if not ([ len(cast(HeadlineDict, hl)['orig'].group('stars')) for hl in self.headline_hierarchy ] + == list(range(1, len(self.headline_hierarchy) + 1))): + raise AssertionError('Error on Headline Hierarchy') else: # This might happen if headlines with more that 1 level deeper are found pass @@ -2497,13 +2135,9 @@ class OrgDocReader: checkbox_indentation=match.group("checkbox_indentation"), checkbox_value=match.group("checkbox_value"), tag_indentation=match.group("tag_indentation"), - tag=( - parse_content_block( - [RawLine(linenum=linenum, line=match.group("tag"))] - ).contents - if match.group("tag") - else None - ), + tag=parse_content_block( + [RawLine(linenum=linenum, line=match.group("tag"))] + ).contents if match.group("tag") else None, content=parse_content_block( [RawLine(linenum=linenum, line=match.group("content"))] ).contents, @@ -2517,14 +2151,14 @@ class OrgDocReader: return li def add_table_line(self, linenum: int, line: str): - chunks = line.split("|") + chunks = line.split('|') indentation = len(chunks[0]) - if chunks[-1].strip() == "": + if chunks[-1].strip() == '': suffix = chunks[-1] cells = chunks[1:-1] last_cell_closed = True else: - suffix = "" + suffix = '' cells = chunks[1:] last_cell_closed = False @@ -2566,13 +2200,8 @@ class OrgDocReader: self.headline_hierarchy[-1]["contents"].append(raw) def add_begin_block_line(self, linenum: int, match: re.Match): - line = DelimiterLine( - linenum, - match.group(0), - DelimiterLineType.BEGIN_BLOCK, - BlockDelimiterTypeData(match.group("subtype")), - match.group("arguments"), - ) + line = DelimiterLine(linenum, match.group(0), DelimiterLineType.BEGIN_BLOCK, + BlockDelimiterTypeData(match.group("subtype")), match.group('arguments')) if len(self.headline_hierarchy) == 0: self.delimiters.append(line) else: @@ -2580,13 +2209,8 @@ class OrgDocReader: self.headline_hierarchy[-1]["delimiters"].append(line) def add_end_block_line(self, linenum: int, match: re.Match): - line = DelimiterLine( - linenum, - match.group(0), - DelimiterLineType.END_BLOCK, - BlockDelimiterTypeData(match.group("subtype")), - None, - ) + line = DelimiterLine(linenum, match.group(0), DelimiterLineType.END_BLOCK, + BlockDelimiterTypeData(match.group("subtype")), None) if len(self.headline_hierarchy) == 0: self.delimiters.append(line) else: @@ -2634,7 +2258,7 @@ class OrgDocReader: self.current_drawer.append(Property(linenum, match, key, value, None)) - def read(self, s): + def read(self, s, environment): lines = s.split("\n") line_count = len(lines) reader = enumerate(lines) @@ -2648,8 +2272,8 @@ class OrgDocReader: nonlocal list_item nonlocal list_item_indentation if list_item: - if (line[: list_item.text_start_pos].strip() == "") or ( - len(line.strip()) == 0 + if ((line[:list_item.text_start_pos].strip() == '') + or (len(line.strip()) == 0) ): list_item.append_line(line) added = True @@ -2712,7 +2336,7 @@ class OrgDocReader: list_item = None elif m := NODE_PROPERTIES_RE.match(line): self.add_node_properties_line(linenum, m) - elif line.strip().startswith("|"): + elif line.strip().startswith('|'): self.add_table_line(linenum, line) list_item_indentation = None list_item = None @@ -2725,8 +2349,8 @@ class OrgDocReader: def loads(s, environment=BASE_ENVIRONMENT, extra_cautious=True): - reader = OrgDocReader(environment) - reader.read(s) + reader = OrgDocReader() + reader.read(s, environment) doc = reader.finalize() if extra_cautious: # Check that all options can be properly re-serialized after_dump = dumps(doc) @@ -2758,9 +2382,7 @@ def loads(s, environment=BASE_ENVIRONMENT, extra_cautious=True): context_last_line = None # print("---\n" + after_dump + "\n---") - raise NonReproducibleDocument( - "Difference found between existing version and dumped" - ) + raise NonReproducibleDocument("Difference found between existing version and dumped") return doc diff --git a/org_rw/types.py b/org_rw/types.py index 7bda704..eff7f59 100644 --- a/org_rw/types.py +++ b/org_rw/types.py @@ -1,7 +1,6 @@ import re from typing import List, TypedDict - class HeadlineDict(TypedDict): linenum: int orig: re.Match diff --git a/scripts/upload-to-pip.sh b/scripts/upload-to-pip.sh index b5c55e4..c364cbe 100644 --- a/scripts/upload-to-pip.sh +++ b/scripts/upload-to-pip.sh @@ -5,8 +5,6 @@ set -eu cd "`dirname $0`" cd .. -pandoc README.org -o README.md # PyPI doesn't accept Org files - python setup.py sdist twine upload --verbose dist/* diff --git a/setup.py b/setup.py index 1295538..4ef44b3 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name="org-rw", - version="0.0.2", + version="0.0.1.dev1", description="Library to de/serialize org-files and manipulate them.", author="kenkeiras", author_email="kenkeiras@codigoparallevar.com", diff --git a/tests/test_org.py b/tests/test_org.py index e49c6cf..8631fba 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -1,23 +1,14 @@ +import logging import os import unittest +from datetime import date from datetime import datetime as DT from org_rw import MarkerToken, MarkerType, Timestamp, dumps, load, loads, dom import org_rw -from utils.assertions import ( - BOLD, - CODE, - HL, - ITALIC, - SPAN, - STRIKE, - UNDERLINED, - VERBATIM, - WEB_LINK, - Doc, - Tokens, -) +from utils.assertions import (BOLD, CODE, HL, ITALIC, SPAN, STRIKE, UNDERLINED, + VERBATIM, WEB_LINK, Doc, Tokens) DIR = os.path.dirname(os.path.abspath(__file__)) @@ -292,19 +283,13 @@ class TestSerde(unittest.TestCase): SPAN("\n"), SPAN( " This is a ", - WEB_LINK( - "[tricky web link]\u200b", - "https://codigoparallevar.com/4", - ), + WEB_LINK("[tricky web link]\u200b", "https://codigoparallevar.com/4"), " followed up with some text.\n", ), SPAN("\n"), SPAN( " This is [", - WEB_LINK( - "another tricky web link", - "https://codigoparallevar.com/5", - ), + WEB_LINK("another tricky web link", "https://codigoparallevar.com/5"), "] followed up with some text.\n", ), ], @@ -321,7 +306,7 @@ class TestSerde(unittest.TestCase): ), ], ), - ), + ) ) ex.assert_matches(self, doc) @@ -486,9 +471,7 @@ class TestSerde(unittest.TestCase): + 'echo "with two lines"\n' + "exit 0 # Exit successfully", ) - self.assertEqual( - snippets[0].arguments.split(), ["shell", ":results", "verbatim"] - ) + self.assertEqual(snippets[0].arguments.split(), ['shell', ':results', 'verbatim']) self.assertEqual( snippets[0].result, "This is a test\n" + "with two lines", @@ -506,10 +489,10 @@ class TestSerde(unittest.TestCase): self.assertEqual( snippets[2].content, - "/* This code has to be escaped to\n" - + " * avoid confusion with new headlines.\n" - + " */\n" - + "main(){}", + '/* This code has to be escaped to\n' + + ' * avoid confusion with new headlines.\n' + + ' */\n' + + 'main(){}', ) def test_mimic_write_file_05(self): @@ -547,7 +530,7 @@ class TestSerde(unittest.TestCase): hl_schedule_range = hl.children[1] self.assertEqual( hl_schedule_range.scheduled.time, - Timestamp(True, 2020, 12, 15, "Mar", 0, 5, "++1w"), + Timestamp(True, 2020, 12, 15, "Mar", 0, 5, '++1w') ) self.assertEqual( hl_schedule_range.scheduled.end_time, @@ -555,7 +538,7 @@ class TestSerde(unittest.TestCase): ) self.assertEqual( hl_schedule_range.scheduled.repetition, - "++1w", + '++1w', ) def test_update_info_file_05(self): @@ -608,8 +591,7 @@ class TestSerde(unittest.TestCase): MarkerToken(closing=False, tok_type=MarkerType.UNDERLINED_MODE), "markup", MarkerToken(closing=True, tok_type=MarkerType.UNDERLINED_MODE), - ".", - "\n", + ".", "\n" ], ) @@ -643,24 +625,12 @@ class TestSerde(unittest.TestCase): print(lists4) self.assertEqual(len(lists4), 2) - self.assertEqual( - lists4[0][0].content, - ["This is a list item...", "\n that spans multiple lines", "\n"], - ) + self.assertEqual(lists4[0][0].content, ["This is a list item...", "\n that spans multiple lines", "\n"]) self.assertEqual(lists4[0][0].bullet, "-") - self.assertEqual( - lists4[0][1].content, - [ - "This is another list item...", - "\n that has content on multiple lines", - "\n", - ], - ) + self.assertEqual(lists4[0][1].content, ["This is another list item...", "\n that has content on multiple lines", "\n"]) self.assertEqual(lists4[0][1].bullet, "-") - self.assertEqual( - lists4[1][0].content, ["This is another", "\n multiline list", "\n"] - ) + self.assertEqual(lists4[1][0].content, ["This is another", "\n multiline list", "\n"]) self.assertEqual(lists4[1][0].bullet, "-") def test_org_roam_07(self): @@ -704,22 +674,20 @@ class TestSerde(unittest.TestCase): """.strip(), ) + def test_markup_file_09(self): with open(os.path.join(DIR, "09-markup-on-headline.org")) as f: doc = load(f) hl = doc.getTopHeadlines()[0] print(hl.title) - self.assertEqual( - hl.title.contents, - [ - "Headline ", - MarkerToken(closing=False, tok_type=MarkerType.UNDERLINED_MODE), - "with", - MarkerToken(closing=True, tok_type=MarkerType.UNDERLINED_MODE), - " markup", - ], - ) + self.assertEqual(hl.title.contents, [ + 'Headline ', + MarkerToken(closing=False, tok_type=MarkerType.UNDERLINED_MODE), + 'with', + MarkerToken(closing=True, tok_type=MarkerType.UNDERLINED_MODE), + ' markup', + ]) def test_mimic_write_file_10(self): with open(os.path.join(DIR, "10-tables.org")) as f: @@ -740,9 +708,9 @@ class TestSerde(unittest.TestCase): print(first_table[0]) self.assertEqual(len(first_table[0].cells), 3) - self.assertEqual(first_table[0].cells[0].strip(), "Header1") - self.assertEqual(first_table[0].cells[1].strip(), "Header2") - self.assertEqual(first_table[0].cells[2].strip(), "Header3") + self.assertEqual(first_table[0].cells[0].strip(), 'Header1') + self.assertEqual(first_table[0].cells[1].strip(), 'Header2') + self.assertEqual(first_table[0].cells[2].strip(), 'Header3') hl = hl.children[0] @@ -752,9 +720,9 @@ class TestSerde(unittest.TestCase): print(first_table[0]) self.assertEqual(len(first_table[0].cells), 3) - self.assertEqual(first_table[0].cells[0].strip(), "Header1") - self.assertEqual(first_table[0].cells[1].strip(), "Header2") - self.assertEqual(first_table[0].cells[2].strip(), "Header3") + self.assertEqual(first_table[0].cells[0].strip(), 'Header1') + self.assertEqual(first_table[0].cells[1].strip(), 'Header2') + self.assertEqual(first_table[0].cells[2].strip(), 'Header3') def test_tables_html_file_10(self): with open(os.path.join(DIR, "10-tables.org")) as f: @@ -764,26 +732,27 @@ class TestSerde(unittest.TestCase): tree = hl.as_dom() non_props = [ - item for item in tree if not isinstance(item, dom.PropertyDrawerNode) + item + for item in tree + if not isinstance(item, dom.PropertyDrawerNode) ] - self.assertTrue( - isinstance(non_props[0], dom.Text) - and isinstance(non_props[1], dom.TableNode) - and isinstance(non_props[2], dom.Text), - "Expected ", - ) + self.assertTrue(isinstance(non_props[0], dom.Text) + and isinstance(non_props[1], dom.TableNode) + and isinstance(non_props[2], dom.Text), + 'Expected
') + hl = hl.children[0] tree = hl.as_dom() non_props = [ item for item in tree - if not ( - isinstance(item, dom.PropertyDrawerNode) or isinstance(item, dom.Text) - ) + if not (isinstance(item, dom.PropertyDrawerNode) + or isinstance(item, dom.Text)) ] print_tree(non_props) - self.assertTrue(len(non_props) == 1, "Expected , with only (1) element") + self.assertTrue(len(non_props) == 1, + 'Expected , with only (1) element') def test_nested_lists_html_file_11(self): with open(os.path.join(DIR, "11-nested-lists.org")) as f: @@ -793,38 +762,30 @@ class TestSerde(unittest.TestCase): tree = hl.as_dom() non_props = [ - item for item in tree if not isinstance(item, dom.PropertyDrawerNode) + item + for item in tree + if not isinstance(item, dom.PropertyDrawerNode) ] print_tree(non_props) - self.assertTrue( - (len(non_props) == 1) and (isinstance(non_props[0], dom.ListGroupNode)), - "Expected only as top level", - ) + self.assertTrue((len(non_props) == 1) and (isinstance(non_props[0], dom.ListGroupNode)), + 'Expected only as top level') dom_list = non_props[0] children = dom_list.children - self.assertTrue( - len(children) == 5, "Expected 5 items inside , 3 texts and 2 sublists" - ) + self.assertTrue(len(children) == 5, 'Expected 5 items inside , 3 texts and 2 sublists') # Assert texts - self.assertEqual(children[0].content, ["1"]) - self.assertEqual(children[2].content, ["2"]) - self.assertEqual( - children[4].content[0], "3" - ) # Might be ['3', '\n'] but shouldn't be a breaking change + self.assertEqual(children[0].content, ['1']) + self.assertEqual(children[2].content, ['2']) + self.assertEqual(children[4].content[0], '3') # Might be ['3', '\n'] but shouldn't be a breaking change # Assert lists - self.assertTrue( - isinstance(children[1], dom.ListGroupNode), 'Expected sublist inside "1"' - ) - self.assertEqual(children[1].children[0].content, ["1.1"]) - self.assertEqual(children[1].children[1].content, ["1.2"]) - self.assertTrue( - isinstance(children[3], dom.ListGroupNode), 'Expected sublist inside "2"' - ) - self.assertEqual(children[3].children[0].content, ["2.1"]) - self.assertEqual(children[3].children[1].content, ["2.2"]) + self.assertTrue(isinstance(children[1], dom.ListGroupNode), 'Expected sublist inside "1"') + self.assertEqual(children[1].children[0].content, ['1.1']) + self.assertEqual(children[1].children[1].content, ['1.2']) + self.assertTrue(isinstance(children[3], dom.ListGroupNode), 'Expected sublist inside "2"') + self.assertEqual(children[3].children[0].content, ['2.1']) + self.assertEqual(children[3].children[1].content, ['2.2']) def test_mimic_write_file_12(self): with open(os.path.join(DIR, "12-headlines-with-skip-levels.org")) as f: @@ -833,38 +794,6 @@ 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 print_tree(tree, indentation=0, headline=None): for element in tree: @@ -883,10 +812,6 @@ def print_element(element, indentation, headline): if isinstance(element, org_rw.Link): print(" " * indentation * 2, "Link:", element.get_raw()) elif isinstance(element, str): - print( - " " * indentation * 2, - "Str[" + element.replace("\n", "") + "]", - type(element), - ) + print(" " * indentation * 2, "Str[" + element.replace('\n', '') + "]", type(element)) else: print_tree(element, indentation, headline) diff --git a/tests/test_timestamp.py b/tests/test_timestamp.py deleted file mode 100644 index 7d69d13..0000000 --- a/tests/test_timestamp.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Test the Timestamp object.""" - -import pytest -from datetime import date, datetime -from org_rw import Timestamp - - -def test_init_with_datetime() -> None: - datetime_obj: datetime = datetime(2024, 7, 20, 15, 45) - - ts: Timestamp = Timestamp(active=True, datetime_=datetime_obj) - - assert ts.active is True - assert ts._year == 2024 - assert ts._month == 7 - assert ts._day == 20 - assert ts.hour == 15 - assert ts.minute == 45 - assert ts.dow is None - assert ts.repetition is None - - -def test_init_with_date() -> None: - date_obj: date = date(2024, 7, 20) - - ts: Timestamp = Timestamp(active=True, datetime_=date_obj) - - assert ts.active is True - assert ts._year == 2024 - assert ts._month == 7 - assert ts._day == 20 - assert ts.hour is None - assert ts.minute is None - assert ts.dow is None - assert ts.repetition is None - - -def test_init_with_year_month_day() -> None: - ts: Timestamp = Timestamp( - active=True, - year=2024, - month=7, - day=20, - hour=15, - minute=45, - dow="Saturday", - repetition=".+1d", - ) - - assert ts.active is True - assert ts._year == 2024 - assert ts._month == 7 - assert ts._day == 20 - assert ts.hour == 15 - assert ts.minute == 45 - assert ts.dow == "Saturday" - assert ts.repetition == ".+1d" - - -def test_init_without_required_arguments() -> None: - with pytest.raises(ValueError): - Timestamp(active=True) - - -def test_init_with_partial_date_info() -> None: - with pytest.raises(ValueError): - Timestamp(active=True, year=2024, month=7) - - -def test_init_with_datetime_overrides_date_info() -> None: - datetime_obj: datetime = datetime(2024, 7, 20, 15, 45) - - ts: Timestamp = Timestamp( - active=True, year=2020, month=1, day=1, datetime_=datetime_obj - ) - - assert ts.active is True - assert ts._year == 2024 - assert ts._month == 7 - assert ts._day == 20 - assert ts.hour == 15 - assert ts.minute == 45 - assert ts.dow is None - assert ts.repetition is None