From 4fd29819eacf66e801a662e6202d4cffcff217c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Mart=C3=ADnez=20Portela?= Date: Wed, 21 Feb 2024 23:00:59 +0100 Subject: [PATCH 1/6] Fix implicit link parsing. --- org_rw/org_rw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index b1dff79..3217430 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -94,7 +94,7 @@ LIST_ITEM_RE = re.compile( r"(?P\s*)((?P[*\-+])|((?P\d|[a-zA-Z])(?P[.)]))) ((?P\s*)\[(?P[ Xx])\])?((?P\s*)(?P.*?)::)?(?P.*)" ) -IMPLICIT_LINK_RE = re.compile(r'(https?:[^<> ]*[a-zA-Z])') +IMPLICIT_LINK_RE = re.compile(r'(https?:[^<> ]*[a-zA-Z0-9])') # Org-Babel BEGIN_BLOCK_RE = re.compile(r"^\s*#\+BEGIN_(?P[^ ]+)(?P.*)$", re.I) -- 2.45.2 From c5cc14f65c03173bfb24951db709100e79286ee7 Mon Sep 17 00:00:00 2001 From: Lyz Date: Fri, 19 Jul 2024 21:36:00 +0200 Subject: [PATCH 2/6] feat(Timestamp): add the from_datetime method To update the current Timestamp instance based on a datetime or date object. I've also included a set_datetime method to OrgTime feat: add activate and deactivate methods to TimeRange and OrgTime I need it in a program I'm making refactor: Create the Time type hint I had to move the parse_time and parse_org_time_range below OrgTime because it used the Time type hint and the Time type hint needed the other two style: reformat the code following black style: Add some type hints and docstrings style: remove unused imports tests: Correct some mypy errors --- org_rw/org_rw.py | 653 +++++++++++++++++++++++++++++++++------------- org_rw/types.py | 1 + tests/test_org.py | 163 +++++++----- 3 files changed, 581 insertions(+), 236 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 3217430..b4067ba 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1,7 +1,7 @@ from __future__ import annotations - +from typing import Optional +from datetime import timedelta import collections -from ctypes import ArgumentError import difflib import logging import os @@ -9,12 +9,13 @@ import re import sys from datetime import date, datetime, timedelta from enum import Enum -from typing import cast, Iterator, List, Literal, Optional, Tuple, Union +from typing import cast, Iterator, List, Optional, Tuple, Union from .types import HeadlineDict from . import dom + DEBUG_DIFF_CONTEXT = 10 BASE_ENVIRONMENT = { @@ -94,16 +95,23 @@ LIST_ITEM_RE = re.compile( r"(?P\s*)((?P[*\-+])|((?P\d|[a-zA-Z])(?P[.)]))) ((?P\s*)\[(?P[ Xx])\])?((?P\s*)(?P.*?)::)?(?P.*)" ) -IMPLICIT_LINK_RE = re.compile(r'(https?:[^<> ]*[a-zA-Z0-9])') +IMPLICIT_LINK_RE = re.compile(r"(https?:[^<> ]*[a-zA-Z0-9])") # Org-Babel BEGIN_BLOCK_RE = re.compile(r"^\s*#\+BEGIN_(?P[^ ]+)(?P.*)$", re.I) END_BLOCK_RE = re.compile(r"^\s*#\+END_(?P[^ ]+)\s*$", re.I) RESULTS_DRAWER_RE = re.compile(r"^\s*:results:\s*$", re.I) -CodeSnippet = collections.namedtuple("CodeSnippet", ("name", "content", "result", "arguments")) +CodeSnippet = collections.namedtuple( + "CodeSnippet", ("name", "content", "result", "arguments") +) # Groupings -NON_FINISHED_GROUPS = (type(None), dom.ListGroupNode, dom.ResultsDrawerNode, dom.PropertyDrawerNode) +NON_FINISHED_GROUPS = ( + type(None), + dom.ListGroupNode, + dom.ResultsDrawerNode, + dom.PropertyDrawerNode, +) FREE_GROUPS = (dom.CodeBlock,) @@ -112,6 +120,7 @@ class NonReproducibleDocument(Exception): Exception thrown when a document would be saved as different contents from what it's loaded from. """ + pass @@ -164,20 +173,19 @@ def unescape_block_lines(block: str) -> str: Remove leading ',' from block_lines if they escape `*` characters. """ i = 0 - lines = block.split('\n') + lines = block.split("\n") while i < len(lines): line = lines[i] - if (line.lstrip(' ').startswith(',') - and line.lstrip(' ,').startswith('*') - ): + if line.lstrip(" ").startswith(",") and line.lstrip(" ,").startswith("*"): # Remove leading ',' - lead_pos = line.index(',') - line = line[:lead_pos] + line[lead_pos + 1:] + lead_pos = line.index(",") + line = line[:lead_pos] + line[lead_pos + 1 :] lines[i] = line i += 1 - return '\n'.join(lines) + return "\n".join(lines) + def get_links_from_content(content): in_link = False @@ -211,11 +219,8 @@ def get_links_from_content(content): elif isinstance(tok, str): implicit_links = IMPLICIT_LINK_RE.findall(tok) for link in implicit_links: - yield Link( - cast(str, link), - cast(str, link), - None - ) + yield Link(cast(str, link), cast(str, link), None) + def text_to_dom(tokens, item): if tokens is None: @@ -237,11 +242,13 @@ def text_to_dom(tokens, item): in_description = True elif tok.tok_type == LinkTokenType.CLOSE: rng = RangeInRaw(item, open_link_token, tok) - contents.append(Link( - "".join(link_value), - "".join(link_description) if in_description else None, - rng, - )) + contents.append( + Link( + "".join(link_value), + "".join(link_description) if in_description else None, + rng, + ) + ) in_link = False in_description = False link_value = [] @@ -256,6 +263,7 @@ def text_to_dom(tokens, item): return contents + def get_line(item): if isinstance(item, Text): return item.linenum @@ -291,8 +299,8 @@ class Headline: list_items, table_rows, parent, - is_todo, - is_done, + is_todo: bool, + is_done: bool, spacing, ): self.start_line = start_line @@ -303,9 +311,7 @@ class Headline: self.priority_start = priority_start self.priority = priority self.title_start = title_start - self.title = parse_content_block( - [RawLine(linenum=start_line, line=title)] - ) + self.title = parse_content_block([RawLine(linenum=start_line, line=title)]) self.state = state self.tags_start = tags_start self.shallow_tags = tags @@ -318,9 +324,9 @@ class Headline: self.parent = parent self.is_todo = is_todo self.is_done = is_done - self.scheduled = None - self.deadline = None - self.closed = None + self.scheduled: Time = None + self.deadline: Time = None + self.closed: Time = None self.spacing = spacing # Read planning line @@ -362,7 +368,6 @@ class Headline: par = par.parent return par - def as_dom(self): everything = ( self.keywords @@ -400,7 +405,7 @@ class Headline: tree.append(current_node) current_node = None else: - pass # Ignore + pass # Ignore elif isinstance(line, Property): if type(current_node) in NON_FINISHED_GROUPS: @@ -413,22 +418,24 @@ class Headline: tree_up = list(indentation_tree) while len(tree_up) > 0: node: dom.DomNode = tree_up[-1] - if (isinstance(node, dom.BlockNode) - or isinstance(node, dom.DrawerNode) + if isinstance(node, dom.BlockNode) or isinstance( + node, dom.DrawerNode ): node.append(dom.Text(line)) current_node = node contents = None break - elif ((not isinstance(node, dom.TableNode)) and - (type(node) not in NON_FINISHED_GROUPS) + elif (not isinstance(node, dom.TableNode)) and ( + type(node) not in NON_FINISHED_GROUPS ): - raise NotImplementedError('Not implemented node type: {} (headline_id={}, line={}, doc={})'.format( - node, - self.id, - line.linenum, - self.doc.path, - )) + raise NotImplementedError( + "Not implemented node type: {} (headline_id={}, line={}, doc={})".format( + node, + self.id, + line.linenum, + self.doc.path, + ) + ) else: tree_up.pop(-1) else: @@ -438,7 +445,8 @@ class Headline: indentation_tree = tree_up elif isinstance(line, ListItem): - if (current_node is None + if ( + current_node is None or isinstance(current_node, dom.TableNode) or isinstance(current_node, dom.BlockNode) or isinstance(current_node, dom.DrawerNode) @@ -452,7 +460,14 @@ class Headline: indentation_tree.append(current_node) if not isinstance(current_node, dom.ListGroupNode): if not isinstance(current_node, dom.ListGroupNode): - raise Exception("Expected a {}, found: {} on line {} on {}".format(dom.ListGroupNode, current_node, line.linenum, self.doc.path)) + raise Exception( + "Expected a {}, found: {} on line {} on {}".format( + dom.ListGroupNode, + current_node, + line.linenum, + self.doc.path, + ) + ) # This can happen. Frequently inside a LogDrawer if len(indentation_tree) > 0 and ( @@ -478,10 +493,9 @@ class Headline: if isinstance(c, dom.ListItem) ] - if (len(list_children) == 0): + if len(list_children) == 0: break - if ((len(list_children[-1].orig.indentation) - <= len(line.indentation))): + if len(list_children[-1].orig.indentation) <= len(line.indentation): # No more breaking out of lists, it's indentation # is less than ours break @@ -494,7 +508,11 @@ class Headline: else: current_node = indentation_tree[-1] - node = dom.ListItem(text_to_dom(line.tag, line), text_to_dom(line.content, line), orig=line) + node = dom.ListItem( + text_to_dom(line.tag, line), + text_to_dom(line.content, line), + orig=line, + ) current_node.append(node) elif isinstance(line, TableRow): @@ -511,10 +529,18 @@ class Headline: list_node.append(current_node) indentation_tree.append(current_node) else: - logging.debug("Expected a {}, found: {} on line {}".format(dom.TableNode, current_node, line.linenum)) + logging.debug( + "Expected a {}, found: {} on line {}".format( + dom.TableNode, current_node, line.linenum + ) + ) # This can happen. Frequently inside a LogDrawer - if len(line.cells) > 0 and len(line.cells[0]) > 0 and line.cells[0][0] == '-': + if ( + len(line.cells) > 0 + and len(line.cells[0]) > 0 + and line.cells[0][0] == "-" + ): node = dom.TableSeparatorRow(orig=line) else: node = dom.TableRow(line.cells, orig=line) @@ -526,7 +552,9 @@ class Headline: and line.delimiter_type == DelimiterLineType.BEGIN_BLOCK ): assert type(current_node) in NON_FINISHED_GROUPS - current_node = dom.CodeBlock(line, line.type_data.subtype, line.arguments) + current_node = dom.CodeBlock( + line, line.type_data.subtype, line.arguments + ) elif isinstance(line, Keyword): logging.warning("Keywords not implemented on `as_dom()`") @@ -560,7 +588,7 @@ class Headline: indentation_tree = [current_node] elif content.strip().upper() == ":END:": if current_node is None and len(indentation_tree) == 0: - logging.error('Finished node (:END:) with no known starter') + logging.error("Finished node (:END:) with no known starter") else: tree_up = list(indentation_tree) while len(tree_up) > 0: @@ -573,7 +601,11 @@ class Headline: else: tree_up.pop(-1) else: - raise Exception('Unexpected node ({}) on headline (id={}), line {}'.format(current_node, self.id, linenum)) + raise Exception( + "Unexpected node ({}) on headline (id={}), line {}".format( + current_node, self.id, linenum + ) + ) current_node = None elif content.strip().upper() == ":RESULTS:": assert current_node is None @@ -598,19 +630,22 @@ class Headline: lists.append([li]) else: num_lines = li.linenum - (last_line + 1) - lines_between = ''.join(['\n' + l - for l in self.get_lines_between(last_line + 1, li.linenum)] - ) + lines_between = "".join( + [ + "\n" + l + for l in self.get_lines_between(last_line + 1, li.linenum) + ] + ) # Only empty lines - if ((num_lines == lines_between.count('\n')) - and (len(lines_between.strip()) == 0) + if (num_lines == lines_between.count("\n")) and ( + len(lines_between.strip()) == 0 ): lists[-1].append(li) else: lists.append([li]) - last_line = li.linenum + sum(c.count('\n') for c in li.content) + last_line = li.linenum + sum(c.count("\n") for c in li.content) return lists # @DEPRECATED: use `get_lists` @@ -677,7 +712,7 @@ class Headline: time_seg = content[len("CLOCK:") :].strip() - parsed: Union[None, OrgTime, TimeRange] = None + parsed: Time = None if "--" in time_seg: # TODO: Consider duration start, end = time_seg.split("=")[0].split("--") @@ -755,7 +790,7 @@ class Headline: for lst in self.get_lists(): for item in lst: if item.tag: - yield from get_links_from_content(item.tag) + yield from get_links_from_content(item.tag) yield from get_links_from_content(item.content) def get_lines_between(self, start, end): @@ -777,7 +812,7 @@ class Headline: if linenum == line.linenum: return line - for (s_lnum, struc) in self.structural: + for s_lnum, struc in self.structural: if linenum == s_lnum: return ("structural", struc) @@ -803,7 +838,7 @@ class Headline: ) def get_structural_end_after(self, linenum): - for (s_lnum, struc) in self.structural: + for s_lnum, struc in self.structural: if s_lnum > linenum and struc.strip().upper() == ":END:": return (s_lnum, struc) @@ -814,11 +849,17 @@ class Headline: arguments = None for delimiter in self.delimiters: - if delimiter.delimiter_type == DelimiterLineType.BEGIN_BLOCK and delimiter.type_data.subtype.lower() == "src": + if ( + delimiter.delimiter_type == DelimiterLineType.BEGIN_BLOCK + and delimiter.type_data.subtype.lower() == "src" + ): line_start = delimiter.linenum inside_code = True arguments = delimiter.arguments - elif delimiter.delimiter_type == DelimiterLineType.END_BLOCK and delimiter.type_data.subtype.lower() == "src": + elif ( + delimiter.delimiter_type == DelimiterLineType.END_BLOCK + and delimiter.type_data.subtype.lower() == "src" + ): inside_code = False start, end = line_start, delimiter.linenum @@ -889,7 +930,11 @@ class Headline: content = section["content"] code_result = section.get("result", None) arguments = section.get("arguments", None) - results.append(CodeSnippet(name=name, content=content, result=code_result, arguments=arguments)) + results.append( + CodeSnippet( + name=name, content=content, result=code_result, arguments=arguments + ) + ) return results @@ -931,13 +976,20 @@ Property = collections.namedtuple( "Property", ("linenum", "match", "key", "value", "options") ) + class ListItem: - def __init__(self, - linenum, match, + def __init__( + self, + linenum, + match, indentation, - bullet, counter, counter_sep, - checkbox_indentation, checkbox_value, - tag_indentation, tag, + bullet, + counter, + counter_sep, + checkbox_indentation, + checkbox_value, + tag_indentation, + tag, content, ): self.linenum = linenum @@ -954,10 +1006,11 @@ class ListItem: @property def text_start_pos(self): - return len(self.indentation) + 1 # Indentation + bullet + return len(self.indentation) + 1 # Indentation + bullet def append_line(self, line): - self.content += parse_content_block('\n' + line).contents + self.content += parse_content_block("\n" + line).contents + TableRow = collections.namedtuple( "TableRow", @@ -970,10 +1023,34 @@ TableRow = collections.namedtuple( ), ) + # @TODO How are [YYYY-MM-DD HH:mm--HH:mm] and ([... HH:mm]--[... HH:mm]) differentiated ? # @TODO Consider recurrence annotations class Timestamp: - def __init__(self, active, year, month, day, dow, hour, minute, repetition=None): + def __init__( + self, + active: bool, + year: int, + month: int, + day: int, + dow: Optional[str], + hour: Optional[int], + minute: Optional[int], + repetition: Optional[str] = None, + ): + """ + Initializes a Timestamp instance. + + Args: + active (bool): Whether the timestamp is active. + year (int): The year of the timestamp. + month (int): The month of the timestamp. + day (int): The day of the timestamp. + dow (Optional[str]): The day of the week, if any. + hour (Optional[int]): The hour of the timestamp, if any. + minute (Optional[int]): The minute of the timestamp, if any. + repetition (Optional[str]): The repetition pattern, if any. + """ self.active = active self._year = year self._month = month @@ -984,12 +1061,51 @@ class Timestamp: self.repetition = repetition def to_datetime(self) -> datetime: + """ + Converts the Timestamp to a datetime object. + + Returns: + datetime: The corresponding datetime object. + """ if self.hour is not None: return datetime(self.year, self.month, self.day, self.hour, self.minute) else: return datetime(self.year, self.month, self.day, 0, 0) - def __add__(self, delta: timedelta): + def from_datetime(self, dt: Union[datetime, date]) -> None: + """ + Updates the current Timestamp instance based on a datetime or date object. + + Args: + dt (Union[datetime, date]): The datetime or date object to use for updating the instance. + """ + if isinstance(dt, datetime): + self._year = dt.year + self._month = dt.month + self._day = dt.day + self.hour = dt.hour + self.minute = dt.minute + elif isinstance(dt, date): + self._year = dt.year + self._month = dt.month + self._day = dt.day + self.hour = None + self.minute = None + else: + raise TypeError("Expected datetime or date object") + + self.dow = None # Day of the week can be set to None + + def __add__(self, delta: timedelta) -> "Timestamp": + """ + Adds a timedelta to the Timestamp. + + Args: + delta (timedelta): The time difference to add. + + Returns: + Timestamp: The resulting Timestamp instance. + """ as_dt = self.to_datetime() to_dt = as_dt + delta @@ -1000,64 +1116,102 @@ class Timestamp: day=to_dt.day, dow=None, hour=to_dt.hour if self.hour is not None or to_dt.hour != 0 else None, - minute=to_dt.minute - if self.minute is not None or to_dt.minute != 0 - else None, + minute=( + to_dt.minute if self.minute is not None or to_dt.minute != 0 else None + ), repetition=self.repetition, ) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + """ + Checks if two Timestamp instances are equal. + + Args: + other (object): The other object to compare with. + + Returns: + bool: True if the instances are equal, False otherwise. + """ if not isinstance(other, Timestamp): return False return ( - (self.active == other.active) - and (self.year == other.year) - and (self.month == other.month) - and (self.day == other.day) - and (self.dow == other.dow) - and (self.hour == other.hour) - and (self.minute == other.minute) - and (self.repetition == other.repetition) + self.active == other.active + and self.year == other.year + and self.month == other.month + and self.day == other.day + and self.dow == other.dow + and self.hour == other.hour + and self.minute == other.minute + and self.repetition == other.repetition ) - def __lt__(self, other): + def __lt__(self, other: object) -> bool: + """ + Checks if the Timestamp is less than another Timestamp. + + Args: + other (object): The other object to compare with. + + Returns: + bool: True if this Timestamp is less than the other, False otherwise. + """ if not isinstance(other, Timestamp): return False return self.to_datetime() < other.to_datetime() - def __gt__(self, other): + def __gt__(self, other: object) -> bool: + """ + Checks if the Timestamp is greater than another Timestamp. + + Args: + other (object): The other object to compare with. + + Returns: + bool: True if this Timestamp is greater than the other, False otherwise. + """ if not isinstance(other, Timestamp): return False return self.to_datetime() > other.to_datetime() - def __repr__(self): + def __repr__(self) -> str: + """ + Returns a string representation of the Timestamp. + + Returns: + str: The string representation of the Timestamp. + """ return timestamp_to_string(self) - # Properties whose modification changes the Day-Of-Week @property - def year(self): + def year(self) -> int: + """Returns the year of the timestamp.""" return self._year @year.setter - def year(self, value): + def year(self, value: int) -> None: + """Sets the year of the timestamp and resets the day of the week.""" self._year = value self.dow = None @property - def month(self): + def month(self) -> int: + """Returns the month of the timestamp.""" return self._month @month.setter - def month(self, value): + def month(self, value: int) -> None: + """Sets the month of the timestamp and resets the day of the week.""" self._month = value self.dow = None @property - def day(self): + def day(self) -> int: + """Returns the day of the timestamp.""" return self._day @day.setter - def day(self, value): + def day(self, value: int) -> None: + """Sets the day of the timestamp and resets the day of the week.""" self._day = value self.dow = None @@ -1067,9 +1221,7 @@ class DelimiterLineType(Enum): END_BLOCK = 2 -BlockDelimiterTypeData = collections.namedtuple( - "BlockDelimiterTypeData", ("subtype") -) +BlockDelimiterTypeData = collections.namedtuple("BlockDelimiterTypeData", ("subtype")) DelimiterLine = collections.namedtuple( "DelimiterLine", ("linenum", "line", "delimiter_type", "type_data", "arguments") @@ -1119,90 +1271,153 @@ def token_from_type(tok_type): class TimeRange: - def __init__(self, start_time: OrgTime, end_time: OrgTime): - assert start_time is not None - assert end_time is not None + """Represents a range of time with a start and end time. + + Attributes: + start_time (OrgTime): The start time of the range. + end_time (OrgTime): The end time of the range. + """ + + def __init__(self, start_time: OrgTime, end_time: OrgTime) -> None: + """Initializes a TimeRange with a start time and an end time. + + Args: + start_time (OrgTime): The start time of the range. + end_time (OrgTime): The end time of the range. + + Raises: + AssertionError: If start_time or end_time is None. + """ + if start_time is None or end_time is None: + raise ValueError("start_time and end_time must not be None.") self.start_time = start_time self.end_time = end_time def to_raw(self) -> str: + """Converts the TimeRange to its raw string representation. + + Returns: + str: The raw string representation of the TimeRange. + """ return timerange_to_string(self) @property def duration(self) -> timedelta: + """Calculates the duration of the TimeRange. + + Returns: + timedelta: The duration between start_time and end_time. + """ delta = self.end - self.start return delta @property def start(self) -> datetime: + """Gets the start time as a datetime object. + + Returns: + datetime: The start time of the TimeRange. + """ return self.start_time.time.to_datetime() @property def end(self) -> datetime: + """Gets the end time as a datetime object. + + Returns: + datetime: The end time of the TimeRange. + """ return self.end_time.time.to_datetime() + def activate(self) -> None: + """ + Sets the active state for the times. + """ + self.start_time.active = True + self.end_time.active = True -def parse_time(value: str) -> Union[None, TimeRange, OrgTime]: - if (value.count(">--<") == 1) or (value.count("]--[") == 1): - # Time ranges with two different dates - # @TODO properly consider "=> DURATION" section - start, end = value.split("=")[0].split("--") - as_time_range = parse_org_time_range(start, end) - if as_time_range is None: - return None - - if (as_time_range.start_time is not None) and ( - as_time_range.end_time is not None - ): - return as_time_range - else: - raise Exception("Unknown time range format: {}".format(value)) - elif as_time := OrgTime.parse(value): - return as_time - else: - return None - - -def parse_org_time_range(start, end) -> Optional[TimeRange]: - start_time = OrgTime.parse(start) - end_time = OrgTime.parse(end) - - if start_time is None or end_time is None: - return None - return TimeRange(start_time, end_time) + def deactivate(self) -> None: + """ + Sets the inactive state for the times. + """ + self.start_time.active = False + self.end_time.active = False class OrgTime: - def __init__(self, ts: Timestamp, end_time: Optional[Timestamp] = None): - assert ts is not None + """Represents a point in time with optional end time and repetition. + + Attributes: + time (Timestamp): The start time of the OrgTime instance. + end_time (Optional[Timestamp]): The end time of the OrgTime instance, if any. + """ + + def __init__(self, ts: Timestamp, end_time: Optional[Timestamp] = None) -> None: + """Initializes an OrgTime with a start time and an optional end time. + + Args: + ts (Timestamp): The start time of the OrgTime instance. + end_time (Optional[Timestamp], optional): The end time of the OrgTime instance. Defaults to None. + + Raises: + ValueError: If ts is None. + """ + if ts is None: + raise ValueError("Timestamp (ts) must not be None.") self.time = ts self.end_time = end_time @property - def repetition(self): + def repetition(self) -> Optional[str]: + """Gets the repetition information from the start time. + + Returns: + Optional[str]: The repetition information, or None if not present. + """ return self.time.repetition @property - def duration(self): + def duration(self) -> timedelta: + """Calculates the duration between the start and end times. + + Returns: + timedelta: The duration between the start and end times. If no end time is present, returns zero timedelta. + """ if self.end_time is None: return timedelta() # No duration - else: - return self.end_time.to_datetime() - self.time.to_datetime() + return self.end_time.to_datetime() - self.time.to_datetime() - def to_raw(self): + def to_raw(self) -> str: + """Converts the OrgTime to its raw string representation. + + Returns: + str: The raw string representation of the OrgTime. + """ return timestamp_to_string(self.time, self.end_time) - def __repr__(self): + def __repr__(self) -> str: + """Provides a string representation of the OrgTime instance. + + Returns: + str: The string representation of the OrgTime. + """ return f"OrgTime({self.to_raw()})" @classmethod - def parse(self, value: str) -> Optional[OrgTime]: + def parse(cls, value: str) -> Optional["OrgTime"]: + """Parses a string into an OrgTime object. + + Args: + value (str): The string representation of the OrgTime. + + Returns: + Optional[OrgTime]: The parsed OrgTime instance, or None if parsing fails. + """ if m := ACTIVE_TIME_STAMP_RE.match(value): active = True elif m := INACTIVE_TIME_STAMP_RE.match(value): active = False else: - # raise ArgumentError("Cannot parse `{}` as OrgTime".format(value)) return None repetition = None @@ -1210,7 +1425,7 @@ class OrgTime: repetition = m.group("repetition").strip() if m.group("end_hour"): - return OrgTime( + return cls( Timestamp( active, int(m.group("year")), @@ -1232,7 +1447,7 @@ class OrgTime: ), ) - return OrgTime( + return cls( Timestamp( active, int(m.group("year")), @@ -1245,6 +1460,29 @@ class OrgTime: ) ) + def activate(self) -> None: + """ + Sets the active state for the timestamp. + """ + self.time.active = True + + def deactivate(self) -> None: + """ + Sets the inactive state for the timestamp. + """ + self.time.active = False + + def set_datetime(self, dt: datetime) -> None: + """ + Updates the timestamp to use the given datetime. + + Args: + dt (datetime): The datetime to update the timestamp with. + """ + self.time = Timestamp.from_datetime(dt) + if self.end_time: + self.end_time = Timestamp.from_datetime(dt) + def time_from_str(s: str) -> Optional[OrgTime]: return OrgTime.parse(s) @@ -1284,6 +1522,39 @@ def timestamp_to_string(ts: Timestamp, end_time: Optional[Timestamp] = None) -> return "[{}]".format(base) +Time = Union[None, TimeRange, OrgTime] + + +def parse_time(value: str) -> Time: + if (value.count(">--<") == 1) or (value.count("]--[") == 1): + # Time ranges with two different dates + # @TODO properly consider "=> DURATION" section + start, end = value.split("=")[0].split("--") + as_time_range = parse_org_time_range(start, end) + if as_time_range is None: + return None + + if (as_time_range.start_time is not None) and ( + as_time_range.end_time is not None + ): + return as_time_range + else: + raise Exception("Unknown time range format: {}".format(value)) + elif as_time := OrgTime.parse(value): + return as_time + else: + return None + + +def parse_org_time_range(start, end) -> Optional[TimeRange]: + start_time = OrgTime.parse(start) + end_time = OrgTime.parse(end) + + if start_time is None or end_time is None: + return None + return TimeRange(start_time, end_time) + + def get_raw(doc): if isinstance(doc, str): return doc @@ -1307,7 +1578,9 @@ class Line: class Link: - def __init__(self, value: str, description: Optional[str], origin: Optional[RangeInRaw]): + def __init__( + self, value: str, description: Optional[str], origin: Optional[RangeInRaw] + ): self._value = value self._description = description self._origin = origin @@ -1324,7 +1597,8 @@ class Link: if self._description: new_contents.append(LinkToken(LinkTokenType.OPEN_DESCRIPTION)) new_contents.append(self._description) - self._origin.update_range(new_contents) + if self._origin is not None: + self._origin.update_range(new_contents) @property def value(self): @@ -1359,6 +1633,7 @@ class Text: def get_raw(self): return token_list_to_raw(self.contents) + def token_list_to_plaintext(tok_list) -> str: contents = [] in_link = False @@ -1383,7 +1658,7 @@ def token_list_to_plaintext(tok_list) -> str: if not in_description: # This might happen when link doesn't have a separate description link_description = link_url - contents.append(''.join(link_description)) + contents.append("".join(link_description)) in_link = False in_description = False @@ -1394,6 +1669,7 @@ def token_list_to_plaintext(tok_list) -> str: return "".join(contents) + def token_list_to_raw(tok_list): contents = [] for chunk in tok_list: @@ -1521,13 +1797,11 @@ TOKEN_TYPE_OPEN_LINK = 3 TOKEN_TYPE_CLOSE_LINK = 4 TOKEN_TYPE_OPEN_DESCRIPTION = 5 -TokenItems = Union[ - Tuple[int, Union[None, str, MarkerToken]], -] +TokenItems = Union[Tuple[int, Union[None, str, MarkerToken]],] def tokenize_contents(contents: str) -> List[TokenItems]: - tokens: List[TokenItems] = [] + tokens: List[TokenItems] = [] last_char = None text: List[str] = [] @@ -1676,7 +1950,7 @@ def parse_contents(raw_contents: List[RawLine]): return [parse_content_block(block) for block in blocks] -def parse_content_block(raw_contents: Union[List[RawLine],str]): +def parse_content_block(raw_contents: Union[List[RawLine], str]): contents_buff = [] if isinstance(raw_contents, str): contents_buff.append(raw_contents) @@ -1693,7 +1967,7 @@ def parse_content_block(raw_contents: Union[List[RawLine],str]): contents: List[Union[str, MarkerToken, LinkToken]] = [] # Use tokens to tag chunks of text with it's container type - for (tok_type, tok_val) in tokens: + for tok_type, tok_val in tokens: if tok_type == TOKEN_TYPE_TEXT: assert isinstance(tok_val, str) contents.append(tok_val) @@ -1720,17 +1994,21 @@ def dump_contents(raw): elif isinstance(raw, ListItem): bullet = raw.bullet if raw.bullet else raw.counter + raw.counter_sep content_full = token_list_to_raw(raw.content) - content_lines = content_full.split('\n') - content = '\n'.join(content_lines) + content_lines = content_full.split("\n") + content = "\n".join(content_lines) checkbox = f"[{raw.checkbox_value}]" if raw.checkbox_value else "" - tag = f"{raw.tag_indentation}{token_list_to_raw(raw.tag or '')}::" if raw.tag or raw.tag_indentation else "" + tag = ( + f"{raw.tag_indentation}{token_list_to_raw(raw.tag or '')}::" + if raw.tag or raw.tag_indentation + else "" + ) return ( raw.linenum, f"{raw.indentation}{bullet} {checkbox}{tag}{content}", ) elif isinstance(raw, TableRow): - closed = '|' if raw.last_cell_closed else '' + closed = "|" if raw.last_cell_closed else "" return ( raw.linenum, f"{' ' * raw.indentation}|{'|'.join(raw.cells)}{closed}{raw.suffix}", @@ -1774,7 +2052,9 @@ def parse_headline(hl, doc, parent) -> Headline: contents = parse_contents(hl["contents"]) if not (isinstance(parent, OrgDoc) or depth > parent.depth): - raise AssertionError("Incorrectly parsed parent on `{}' > `{}'".format(parent.title, title)) + raise AssertionError( + "Incorrectly parsed parent on `{}' > `{}'".format(parent.title, title) + ) headline = Headline( start_line=hl["linenum"], @@ -1892,7 +2172,7 @@ class OrgDoc: Created by org-roam v2. """ for p in self.properties: - if p.key == 'ID': + if p.key == "ID": return p.value return None @@ -2112,9 +2392,14 @@ class OrgDocReader: self.headline_hierarchy.append(headline) if all([hl is not None for hl in self.headline_hierarchy]): - if not ([ len(cast(HeadlineDict, hl)['orig'].group('stars')) for hl in self.headline_hierarchy ] - == list(range(1, len(self.headline_hierarchy) + 1))): - raise AssertionError('Error on Headline Hierarchy') + if not ( + [ + len(cast(HeadlineDict, hl)["orig"].group("stars")) + for hl in self.headline_hierarchy + ] + == list(range(1, len(self.headline_hierarchy) + 1)) + ): + raise AssertionError("Error on Headline Hierarchy") else: # This might happen if headlines with more that 1 level deeper are found pass @@ -2135,9 +2420,13 @@ class OrgDocReader: checkbox_indentation=match.group("checkbox_indentation"), checkbox_value=match.group("checkbox_value"), tag_indentation=match.group("tag_indentation"), - tag=parse_content_block( - [RawLine(linenum=linenum, line=match.group("tag"))] - ).contents if match.group("tag") else None, + tag=( + parse_content_block( + [RawLine(linenum=linenum, line=match.group("tag"))] + ).contents + if match.group("tag") + else None + ), content=parse_content_block( [RawLine(linenum=linenum, line=match.group("content"))] ).contents, @@ -2151,14 +2440,14 @@ class OrgDocReader: return li def add_table_line(self, linenum: int, line: str): - chunks = line.split('|') + chunks = line.split("|") indentation = len(chunks[0]) - if chunks[-1].strip() == '': + if chunks[-1].strip() == "": suffix = chunks[-1] cells = chunks[1:-1] last_cell_closed = True else: - suffix = '' + suffix = "" cells = chunks[1:] last_cell_closed = False @@ -2200,8 +2489,13 @@ class OrgDocReader: self.headline_hierarchy[-1]["contents"].append(raw) def add_begin_block_line(self, linenum: int, match: re.Match): - line = DelimiterLine(linenum, match.group(0), DelimiterLineType.BEGIN_BLOCK, - BlockDelimiterTypeData(match.group("subtype")), match.group('arguments')) + line = DelimiterLine( + linenum, + match.group(0), + DelimiterLineType.BEGIN_BLOCK, + BlockDelimiterTypeData(match.group("subtype")), + match.group("arguments"), + ) if len(self.headline_hierarchy) == 0: self.delimiters.append(line) else: @@ -2209,8 +2503,13 @@ class OrgDocReader: self.headline_hierarchy[-1]["delimiters"].append(line) def add_end_block_line(self, linenum: int, match: re.Match): - line = DelimiterLine(linenum, match.group(0), DelimiterLineType.END_BLOCK, - BlockDelimiterTypeData(match.group("subtype")), None) + line = DelimiterLine( + linenum, + match.group(0), + DelimiterLineType.END_BLOCK, + BlockDelimiterTypeData(match.group("subtype")), + None, + ) if len(self.headline_hierarchy) == 0: self.delimiters.append(line) else: @@ -2272,8 +2571,8 @@ class OrgDocReader: nonlocal list_item nonlocal list_item_indentation if list_item: - if ((line[:list_item.text_start_pos].strip() == '') - or (len(line.strip()) == 0) + if (line[: list_item.text_start_pos].strip() == "") or ( + len(line.strip()) == 0 ): list_item.append_line(line) added = True @@ -2336,7 +2635,7 @@ class OrgDocReader: list_item = None elif m := NODE_PROPERTIES_RE.match(line): self.add_node_properties_line(linenum, m) - elif line.strip().startswith('|'): + elif line.strip().startswith("|"): self.add_table_line(linenum, line) list_item_indentation = None list_item = None @@ -2382,7 +2681,9 @@ def loads(s, environment=BASE_ENVIRONMENT, extra_cautious=True): context_last_line = None # print("---\n" + after_dump + "\n---") - raise NonReproducibleDocument("Difference found between existing version and dumped") + raise NonReproducibleDocument( + "Difference found between existing version and dumped" + ) return doc diff --git a/org_rw/types.py b/org_rw/types.py index eff7f59..7bda704 100644 --- a/org_rw/types.py +++ b/org_rw/types.py @@ -1,6 +1,7 @@ import re from typing import List, TypedDict + class HeadlineDict(TypedDict): linenum: int orig: re.Match diff --git a/tests/test_org.py b/tests/test_org.py index 8631fba..f8a1dbb 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -1,14 +1,23 @@ -import logging import os import unittest -from datetime import date from datetime import datetime as DT from org_rw import MarkerToken, MarkerType, Timestamp, dumps, load, loads, dom import org_rw -from utils.assertions import (BOLD, CODE, HL, ITALIC, SPAN, STRIKE, UNDERLINED, - VERBATIM, WEB_LINK, Doc, Tokens) +from utils.assertions import ( + BOLD, + CODE, + HL, + ITALIC, + SPAN, + STRIKE, + UNDERLINED, + VERBATIM, + WEB_LINK, + Doc, + Tokens, +) DIR = os.path.dirname(os.path.abspath(__file__)) @@ -283,13 +292,19 @@ class TestSerde(unittest.TestCase): SPAN("\n"), SPAN( " This is a ", - WEB_LINK("[tricky web link]\u200b", "https://codigoparallevar.com/4"), + WEB_LINK( + "[tricky web link]\u200b", + "https://codigoparallevar.com/4", + ), " followed up with some text.\n", ), SPAN("\n"), SPAN( " This is [", - WEB_LINK("another tricky web link", "https://codigoparallevar.com/5"), + WEB_LINK( + "another tricky web link", + "https://codigoparallevar.com/5", + ), "] followed up with some text.\n", ), ], @@ -306,7 +321,7 @@ class TestSerde(unittest.TestCase): ), ], ), - ) + ), ) ex.assert_matches(self, doc) @@ -471,7 +486,9 @@ class TestSerde(unittest.TestCase): + 'echo "with two lines"\n' + "exit 0 # Exit successfully", ) - self.assertEqual(snippets[0].arguments.split(), ['shell', ':results', 'verbatim']) + self.assertEqual( + snippets[0].arguments.split(), ["shell", ":results", "verbatim"] + ) self.assertEqual( snippets[0].result, "This is a test\n" + "with two lines", @@ -489,10 +506,10 @@ class TestSerde(unittest.TestCase): self.assertEqual( snippets[2].content, - '/* This code has to be escaped to\n' - + ' * avoid confusion with new headlines.\n' - + ' */\n' - + 'main(){}', + "/* This code has to be escaped to\n" + + " * avoid confusion with new headlines.\n" + + " */\n" + + "main(){}", ) def test_mimic_write_file_05(self): @@ -530,7 +547,7 @@ class TestSerde(unittest.TestCase): hl_schedule_range = hl.children[1] self.assertEqual( hl_schedule_range.scheduled.time, - Timestamp(True, 2020, 12, 15, "Mar", 0, 5, '++1w') + Timestamp(True, 2020, 12, 15, "Mar", 0, 5, "++1w"), ) self.assertEqual( hl_schedule_range.scheduled.end_time, @@ -538,7 +555,7 @@ class TestSerde(unittest.TestCase): ) self.assertEqual( hl_schedule_range.scheduled.repetition, - '++1w', + "++1w", ) def test_update_info_file_05(self): @@ -591,7 +608,8 @@ class TestSerde(unittest.TestCase): MarkerToken(closing=False, tok_type=MarkerType.UNDERLINED_MODE), "markup", MarkerToken(closing=True, tok_type=MarkerType.UNDERLINED_MODE), - ".", "\n" + ".", + "\n", ], ) @@ -625,12 +643,24 @@ class TestSerde(unittest.TestCase): print(lists4) self.assertEqual(len(lists4), 2) - self.assertEqual(lists4[0][0].content, ["This is a list item...", "\n that spans multiple lines", "\n"]) + self.assertEqual( + lists4[0][0].content, + ["This is a list item...", "\n that spans multiple lines", "\n"], + ) self.assertEqual(lists4[0][0].bullet, "-") - self.assertEqual(lists4[0][1].content, ["This is another list item...", "\n that has content on multiple lines", "\n"]) + self.assertEqual( + lists4[0][1].content, + [ + "This is another list item...", + "\n that has content on multiple lines", + "\n", + ], + ) self.assertEqual(lists4[0][1].bullet, "-") - self.assertEqual(lists4[1][0].content, ["This is another", "\n multiline list", "\n"]) + self.assertEqual( + lists4[1][0].content, ["This is another", "\n multiline list", "\n"] + ) self.assertEqual(lists4[1][0].bullet, "-") def test_org_roam_07(self): @@ -674,20 +704,22 @@ class TestSerde(unittest.TestCase): """.strip(), ) - def test_markup_file_09(self): with open(os.path.join(DIR, "09-markup-on-headline.org")) as f: doc = load(f) hl = doc.getTopHeadlines()[0] print(hl.title) - self.assertEqual(hl.title.contents, [ - 'Headline ', - MarkerToken(closing=False, tok_type=MarkerType.UNDERLINED_MODE), - 'with', - MarkerToken(closing=True, tok_type=MarkerType.UNDERLINED_MODE), - ' markup', - ]) + self.assertEqual( + hl.title.contents, + [ + "Headline ", + MarkerToken(closing=False, tok_type=MarkerType.UNDERLINED_MODE), + "with", + MarkerToken(closing=True, tok_type=MarkerType.UNDERLINED_MODE), + " markup", + ], + ) def test_mimic_write_file_10(self): with open(os.path.join(DIR, "10-tables.org")) as f: @@ -708,9 +740,9 @@ class TestSerde(unittest.TestCase): print(first_table[0]) self.assertEqual(len(first_table[0].cells), 3) - self.assertEqual(first_table[0].cells[0].strip(), 'Header1') - self.assertEqual(first_table[0].cells[1].strip(), 'Header2') - self.assertEqual(first_table[0].cells[2].strip(), 'Header3') + self.assertEqual(first_table[0].cells[0].strip(), "Header1") + self.assertEqual(first_table[0].cells[1].strip(), "Header2") + self.assertEqual(first_table[0].cells[2].strip(), "Header3") hl = hl.children[0] @@ -720,9 +752,9 @@ class TestSerde(unittest.TestCase): print(first_table[0]) self.assertEqual(len(first_table[0].cells), 3) - self.assertEqual(first_table[0].cells[0].strip(), 'Header1') - self.assertEqual(first_table[0].cells[1].strip(), 'Header2') - self.assertEqual(first_table[0].cells[2].strip(), 'Header3') + self.assertEqual(first_table[0].cells[0].strip(), "Header1") + self.assertEqual(first_table[0].cells[1].strip(), "Header2") + self.assertEqual(first_table[0].cells[2].strip(), "Header3") def test_tables_html_file_10(self): with open(os.path.join(DIR, "10-tables.org")) as f: @@ -732,27 +764,26 @@ class TestSerde(unittest.TestCase): tree = hl.as_dom() non_props = [ - item - for item in tree - if not isinstance(item, dom.PropertyDrawerNode) + item for item in tree if not isinstance(item, dom.PropertyDrawerNode) ] - self.assertTrue(isinstance(non_props[0], dom.Text) - and isinstance(non_props[1], dom.TableNode) - and isinstance(non_props[2], dom.Text), - 'Expected ') - + self.assertTrue( + isinstance(non_props[0], dom.Text) + and isinstance(non_props[1], dom.TableNode) + and isinstance(non_props[2], dom.Text), + "Expected
", + ) hl = hl.children[0] tree = hl.as_dom() non_props = [ item for item in tree - if not (isinstance(item, dom.PropertyDrawerNode) - or isinstance(item, dom.Text)) + if not ( + isinstance(item, dom.PropertyDrawerNode) or isinstance(item, dom.Text) + ) ] print_tree(non_props) - self.assertTrue(len(non_props) == 1, - 'Expected , with only (1) element') + self.assertTrue(len(non_props) == 1, "Expected , with only (1) element") def test_nested_lists_html_file_11(self): with open(os.path.join(DIR, "11-nested-lists.org")) as f: @@ -762,30 +793,38 @@ class TestSerde(unittest.TestCase): tree = hl.as_dom() non_props = [ - item - for item in tree - if not isinstance(item, dom.PropertyDrawerNode) + item for item in tree if not isinstance(item, dom.PropertyDrawerNode) ] print_tree(non_props) - self.assertTrue((len(non_props) == 1) and (isinstance(non_props[0], dom.ListGroupNode)), - 'Expected only as top level') + self.assertTrue( + (len(non_props) == 1) and (isinstance(non_props[0], dom.ListGroupNode)), + "Expected only as top level", + ) dom_list = non_props[0] children = dom_list.children - self.assertTrue(len(children) == 5, 'Expected 5 items inside , 3 texts and 2 sublists') + self.assertTrue( + len(children) == 5, "Expected 5 items inside , 3 texts and 2 sublists" + ) # Assert texts - self.assertEqual(children[0].content, ['1']) - self.assertEqual(children[2].content, ['2']) - self.assertEqual(children[4].content[0], '3') # Might be ['3', '\n'] but shouldn't be a breaking change + self.assertEqual(children[0].content, ["1"]) + self.assertEqual(children[2].content, ["2"]) + self.assertEqual( + children[4].content[0], "3" + ) # Might be ['3', '\n'] but shouldn't be a breaking change # Assert lists - self.assertTrue(isinstance(children[1], dom.ListGroupNode), 'Expected sublist inside "1"') - self.assertEqual(children[1].children[0].content, ['1.1']) - self.assertEqual(children[1].children[1].content, ['1.2']) - self.assertTrue(isinstance(children[3], dom.ListGroupNode), 'Expected sublist inside "2"') - self.assertEqual(children[3].children[0].content, ['2.1']) - self.assertEqual(children[3].children[1].content, ['2.2']) + self.assertTrue( + isinstance(children[1], dom.ListGroupNode), 'Expected sublist inside "1"' + ) + self.assertEqual(children[1].children[0].content, ["1.1"]) + self.assertEqual(children[1].children[1].content, ["1.2"]) + self.assertTrue( + isinstance(children[3], dom.ListGroupNode), 'Expected sublist inside "2"' + ) + self.assertEqual(children[3].children[0].content, ["2.1"]) + self.assertEqual(children[3].children[1].content, ["2.2"]) def test_mimic_write_file_12(self): with open(os.path.join(DIR, "12-headlines-with-skip-levels.org")) as f: @@ -812,6 +851,10 @@ def print_element(element, indentation, headline): if isinstance(element, org_rw.Link): print(" " * indentation * 2, "Link:", element.get_raw()) elif isinstance(element, str): - print(" " * indentation * 2, "Str[" + element.replace('\n', '') + "]", type(element)) + print( + " " * indentation * 2, + "Str[" + element.replace("\n", "") + "]", + type(element), + ) else: print_tree(element, indentation, headline) -- 2.45.2 From f640521b560e66d7679e0d44015de72eff7c5bb6 Mon Sep 17 00:00:00 2001 From: Lyz Date: Sat, 20 Jul 2024 11:14:15 +0200 Subject: [PATCH 3/6] feat: add the scheduled, deadline and closed arguments to Headline init style: Improve the type hints of Time When reading them it's more natural to read Optional[Time] than to assume that None is part of the Union in Time --- org_rw/org_rw.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 0a8383e..ff6d2b3 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -302,6 +302,9 @@ class Headline: is_todo: bool, is_done: bool, spacing, + scheduled: Optional[Time] = None, + deadline: Optional[Time] = None, + closed: Optional[Time] = None, ): self.start_line = start_line self.depth = depth @@ -324,9 +327,9 @@ class Headline: self.parent = parent self.is_todo = is_todo self.is_done = is_done - self.scheduled: Time = None - self.deadline: Time = None - self.closed: Time = None + self.scheduled = scheduled + self.deadline = deadline + self.closed = closed self.spacing = spacing # Read planning line @@ -712,7 +715,7 @@ class Headline: time_seg = content[len("CLOCK:") :].strip() - parsed: Time = None + parsed: Optional[Time] = None if "--" in time_seg: # TODO: Consider duration start, end = time_seg.split("=")[0].split("--") @@ -1522,10 +1525,10 @@ def timestamp_to_string(ts: Timestamp, end_time: Optional[Timestamp] = None) -> return "[{}]".format(base) -Time = Union[None, TimeRange, OrgTime] +Time = Union[TimeRange, OrgTime] -def parse_time(value: str) -> Time: +def parse_time(value: str) -> Optional[Time]: if (value.count(">--<") == 1) or (value.count("]--[") == 1): # Time ranges with two different dates # @TODO properly consider "=> DURATION" section @@ -2151,7 +2154,9 @@ class OrgDoc: for keyword in keywords: if keyword.key in ("TODO", "SEQ_TODO"): - todo_kws, done_kws = re.sub(r"\([^)]+\)", "", keyword.value).split("|", 1) + todo_kws, done_kws = re.sub(r"\([^)]+\)", "", keyword.value).split( + "|", 1 + ) self.todo_keywords = re.sub(r"\s{2,}", " ", todo_kws.strip()).split() self.done_keywords = re.sub(r"\s{2,}", " ", done_kws.strip()).split() -- 2.45.2 From be68d10d7a94f11b70050ee848f0342890bdf0e4 Mon Sep 17 00:00:00 2001 From: Lyz Date: Sat, 20 Jul 2024 11:38:19 +0200 Subject: [PATCH 4/6] feat: initialise a Timestamp from a datetime object --- org_rw/org_rw.py | 49 +++++++++++++++--------- tests/test_timestamp.py | 84 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 tests/test_timestamp.py diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index ff6d2b3..39fbef7 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1033,34 +1033,47 @@ class Timestamp: def __init__( self, active: bool, - year: int, - month: int, - day: int, - dow: Optional[str], - hour: Optional[int], - minute: Optional[int], + year: Optional[int] = None, + month: Optional[int] = None, + day: Optional[int] = None, + dow: Optional[str] = None, + hour: Optional[int] = None, + minute: Optional[int] = None, repetition: Optional[str] = None, + datetime_: Optional[Union[date, datetime]] = None, ): """ Initializes a Timestamp instance. Args: active (bool): Whether the timestamp is active. - year (int): The year of the timestamp. - month (int): The month of the timestamp. - day (int): The day of the timestamp. + year (Optional[int]): The year of the timestamp. + month (Optional[int]): The month of the timestamp. + day (Optional[int]): The day of the timestamp. dow (Optional[str]): The day of the week, if any. hour (Optional[int]): The hour of the timestamp, if any. minute (Optional[int]): The minute of the timestamp, if any. repetition (Optional[str]): The repetition pattern, if any. + datetime_ (Optional[Union[date, datetime]]): A date or datetime object. + + Raises: + ValueError: If neither datetime_ nor the combination of year, month, and day are provided. """ self.active = active - self._year = year - self._month = month - self._day = day - self.dow = dow - self.hour = hour - self.minute = minute + + if datetime_ is not None: + self.from_datetime(datetime_) + elif year is not None and month is not None and day is not None: + self._year = year + self._month = month + self._day = day + self.dow = dow + self.hour = hour + self.minute = minute + else: + raise ValueError( + "Either datetime_ or year, month, and day must be provided." + ) self.repetition = repetition def to_datetime(self) -> datetime: @@ -1475,16 +1488,16 @@ class OrgTime: """ self.time.active = False - def set_datetime(self, dt: datetime) -> None: + def from_datetime(self, dt: datetime) -> None: """ Updates the timestamp to use the given datetime. Args: dt (datetime): The datetime to update the timestamp with. """ - self.time = Timestamp.from_datetime(dt) + self.time.from_datetime(dt) if self.end_time: - self.end_time = Timestamp.from_datetime(dt) + self.end_time.from_datetime(dt) def time_from_str(s: str) -> Optional[OrgTime]: diff --git a/tests/test_timestamp.py b/tests/test_timestamp.py new file mode 100644 index 0000000..14ceeac --- /dev/null +++ b/tests/test_timestamp.py @@ -0,0 +1,84 @@ +"""Test the Timestamp object.""" + +import pytest +from datetime import date, datetime +from org_rw import Timestamp + + +def test_init_with_datetime() -> None: + datetime_obj: datetime = datetime(2024, 7, 20, 15, 45) + + ts: Timestamp = Timestamp(active=True, datetime_=datetime_obj) + + assert ts.active is True + assert ts._year == 2024 + assert ts._month == 7 + assert ts._day == 20 + assert ts.hour == 15 + assert ts.minute == 45 + assert ts.dow is None + assert ts.repetition is None + + +def test_init_with_date() -> None: + date_obj: date = date(2024, 7, 20) + + ts: Timestamp = Timestamp(active=True, datetime_=date_obj) + + assert ts.active is True + assert ts._year == 2024 + assert ts._month == 7 + assert ts._day == 20 + assert ts.hour is None + assert ts.minute is None + assert ts.dow is None + assert ts.repetition is None + + +def test_init_with_year_month_day() -> None: + ts: Timestamp = Timestamp( + active=True, + year=2024, + month=7, + day=20, + hour=15, + minute=45, + dow="Saturday", + repetition="Weekly", + ) + + assert ts.active is True + assert ts._year == 2024 + assert ts._month == 7 + assert ts._day == 20 + assert ts.hour == 15 + assert ts.minute == 45 + assert ts.dow == "Saturday" + assert ts.repetition == "Weekly" + + +def test_init_without_required_arguments() -> None: + with pytest.raises(ValueError): + Timestamp(active=True) + + +def test_init_with_partial_date_info() -> None: + with pytest.raises(ValueError): + Timestamp(active=True, year=2024, month=7) + + +def test_init_with_datetime_overrides_date_info() -> None: + datetime_obj: datetime = datetime(2024, 7, 20, 15, 45) + + ts: Timestamp = Timestamp( + active=True, year=2020, month=1, day=1, datetime_=datetime_obj + ) + + assert ts.active is True + assert ts._year == 2024 + assert ts._month == 7 + assert ts._day == 20 + assert ts.hour == 15 + assert ts.minute == 45 + assert ts.dow is None + assert ts.repetition is None -- 2.45.2 From ff841f82f0548c277ad7284e541c2385d1013ff5 Mon Sep 17 00:00:00 2001 From: Lyz Date: Sat, 20 Jul 2024 11:41:15 +0200 Subject: [PATCH 5/6] feat: Set the default Timestamp active to True That way you don't need to specify it if you don't want --- org_rw/org_rw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py index 39fbef7..4bd7e04 100644 --- a/org_rw/org_rw.py +++ b/org_rw/org_rw.py @@ -1032,7 +1032,7 @@ TableRow = collections.namedtuple( class Timestamp: def __init__( self, - active: bool, + active: bool = True, year: Optional[int] = None, month: Optional[int] = None, day: Optional[int] = None, -- 2.45.2 From 191bb753c46a84dc6b55ab489e44f2ce9b92b059 Mon Sep 17 00:00:00 2001 From: Lyz Date: Fri, 26 Jul 2024 13:34:38 +0200 Subject: [PATCH 6/6] tests: fix repetition string --- tests/test_timestamp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_timestamp.py b/tests/test_timestamp.py index 14ceeac..7d69d13 100644 --- a/tests/test_timestamp.py +++ b/tests/test_timestamp.py @@ -44,7 +44,7 @@ def test_init_with_year_month_day() -> None: hour=15, minute=45, dow="Saturday", - repetition="Weekly", + repetition=".+1d", ) assert ts.active is True @@ -54,7 +54,7 @@ def test_init_with_year_month_day() -> None: assert ts.hour == 15 assert ts.minute == 45 assert ts.dow == "Saturday" - assert ts.repetition == "Weekly" + assert ts.repetition == ".+1d" def test_init_without_required_arguments() -> None: -- 2.45.2