diff --git a/things2reclaim/deadline_status.py b/things2reclaim/deadline_status.py index 28aa661..b965c0c 100644 --- a/things2reclaim/deadline_status.py +++ b/things2reclaim/deadline_status.py @@ -1,5 +1,6 @@ from enum import Enum + class DeadlineStatus(Enum): FINE = 1 OVERDUE = 2 diff --git a/things2reclaim/main.py b/things2reclaim/main.py index 7d72f0c..47ef616 100755 --- a/things2reclaim/main.py +++ b/things2reclaim/main.py @@ -31,9 +31,7 @@ with open(CONFIG_PATH, "rb") as f: DATABASE_PATH = utils.get_project_root() / _config["database"]["path"] -app = typer.Typer( - add_completion=False, no_args_is_help=True -) +app = typer.Typer(add_completion=False, no_args_is_help=True) console = Console() @@ -68,7 +66,7 @@ def things_to_reclaim(things_task): reclaim_handler.create_reaclaim_task_from_dict(params) -def finish_task(task : Union[reclaim_handler.ReclaimTask, str]): +def finish_task(task: Union[reclaim_handler.ReclaimTask, str]): """ Finish task """ @@ -114,14 +112,16 @@ def initialize_uploaded_database(verbose: bool = False): @app.command("upload") -def upload_things_to_reclaim(dry_run : bool = False): +def upload_things_to_reclaim(dry_run: bool = False): """ Upload things tasks to reclaim """ tasks = things_handler.get_all_things_tasks() with UploadedTasksDB(DATABASE_PATH) as db: uploaded_task_ids = db.get_all_uploaded_tasks() - tasks_to_upload = [task for task in tasks if task["uuid"] not in uploaded_task_ids] + tasks_to_upload = [ + task for task in tasks if task["uuid"] not in uploaded_task_ids + ] if not tasks_to_upload: print("No new tasks were found") else: @@ -130,26 +130,26 @@ def upload_things_to_reclaim(dry_run : bool = False): if not dry_run: things_to_reclaim(task) db.add_uploaded_task(task["uuid"]) - print(f"Uploaded {len(tasks_to_upload)} task{'s' if len(tasks_to_upload) > 1 else ''}") + print( + f"Uploaded {len(tasks_to_upload)} task{'s' if len(tasks_to_upload) > 1 else ''}" + ) + - @app.command("list") -def list_reclaim_tasks(subject: Annotated[Optional[str], - typer.Argument()] = None): +def list_reclaim_tasks(subject: Annotated[Optional[str], typer.Argument()] = None): """ List all current tasks """ reclaim_tasks = reclaim_handler.get_reclaim_tasks() if subject is not None: - reclaim_tasks = reclaim_handler.filter_for_subject( - subject, reclaim_tasks) + reclaim_tasks = reclaim_handler.filter_for_subject(subject, reclaim_tasks) current_date = datetime.now(tz.tzutc()) table = Table("Index", "Task", "Days left", title="Task list") for index, task in enumerate(reclaim_tasks): due_date = task.due_date if due_date is None: date_str = Text() - else: + else: if current_date > due_date: days_behind = (current_date - due_date).days date_str = Text(f"{days_behind} days overdue") @@ -158,10 +158,7 @@ def list_reclaim_tasks(subject: Annotated[Optional[str], days_left = (due_date - current_date).days date_str = Text(f"{days_left} days left") date_str.stylize("bold white") - table.add_row( - f"({index + 1})", - task.name, - date_str) + table.add_row(f"({index + 1})", task.name, date_str) console.print(table) @@ -198,43 +195,42 @@ def stop_task(): utils.perror("No task is currently tracked in toggl") return - stopped_task_name = current_task.description + current_task_name = current_task.description - if stopped_task_name is None: + if current_task_name is None: utils.perror("Current toggl task has no name") return - reclaim_dict = { - task.name: task for task in reclaim_handler.get_reclaim_tasks()} + reclaim_dict = {task.name: task for task in reclaim_handler.get_reclaim_tasks()} - if stopped_task_name in reclaim_dict.keys(): - stopped_task = reclaim_dict[stopped_task_name] + if current_task_name in reclaim_dict.keys(): + reclaim_task = reclaim_dict[current_task_name] else: - stopped_task = utils.get_closest_match(stopped_task_name, reclaim_dict) + reclaim_task = utils.get_closest_match(current_task_name, reclaim_dict) - if stopped_task is None: - utils.perror(f"{stopped_task} not found in reclaim") + if reclaim_task is None: + utils.perror(f"{current_task_name} not found in reclaim") return - stop_time = datetime.now(tz.tzutc()) - if callable(current_task.start): - current_task.start = current_task.start() - - toggl_handler.stop_current_task() + stopped_task = toggl_handler.stop_task(current_task) + if stopped_task is None: + utils.perror(f"{current_task_name} could not be stopped") + return + start_time = toggl_handler.get_start_time(stopped_task) + stop_time = toggl_handler.get_stop_time(stopped_task) is_task_finished = Confirm.ask("Is task finished?", default=False) - - if stopped_task.is_scheduled: - reclaim_handler.log_work_for_task( - stopped_task, current_task.start, stop_time) - else: + + try: + reclaim_handler.log_work_for_task(reclaim_task, start_time, stop_time) + except ValueError: utils.pwarning("Work could not be logged in reclaim!") if is_task_finished: - finish_task(stopped_task) - rprint(f"Finished {stopped_task.name}") + finish_task(reclaim_task) + rprint(f"Finished {reclaim_task.name}") - utils.plogtime(current_task.start, stop_time, stopped_task.name) + utils.plogtime(start_time, stop_time, reclaim_task.name) @app.command("stats") @@ -245,20 +241,22 @@ def show_task_stats(): current_date = datetime.now(tz.tzutc()) reclaim_tasks = reclaim_handler.get_reclaim_tasks() - tasks_fine = reclaim_handler.filter_for_deadline_status(current_date, DeadlineStatus.FINE, reclaim_tasks) - tasks_overdue = reclaim_handler.filter_for_deadline_status(current_date, DeadlineStatus.OVERDUE, reclaim_tasks) + tasks_fine = reclaim_handler.filter_for_deadline_status( + current_date, DeadlineStatus.FINE, reclaim_tasks + ) + tasks_overdue = reclaim_handler.filter_for_deadline_status( + current_date, DeadlineStatus.OVERDUE, reclaim_tasks + ) fine_per_course = ["Fine"] overdue_per_course = ["Overdue"] course_names = things_handler.get_course_names() for course_name in course_names: fine_per_course.append( - str(len(reclaim_handler.filter_for_subject - (course_name, tasks_fine))) + str(len(reclaim_handler.filter_for_subject(course_name, tasks_fine))) ) overdue_per_course.append( - str(len(reclaim_handler.filter_for_subject - (course_name, tasks_overdue))) + str(len(reclaim_handler.filter_for_subject(course_name, tasks_overdue))) ) table = Table(*(["Status"] + course_names)) @@ -269,11 +267,13 @@ def show_task_stats(): @app.command("time") -def print_time_needed(): +def print_time_needed(subject: Annotated[Optional[str], typer.Argument()] = None): """ Print sum of time needed for all reclaim tasks """ tasks = reclaim_handler.get_reclaim_tasks() + if subject is not None: + tasks = reclaim_handler.filter_for_subject(subject, tasks) time_needed = 0 for task in tasks: @@ -282,14 +282,22 @@ def print_time_needed(): time_needed += task_duration print(f"Time needed to complete {len(tasks)} Tasks: {time_needed} hrs") - print(f"Average time needed to complete a Task: { - time_needed/len(tasks):.2f} hrs") - + print( + f"Average time needed to complete a Task: { + time_needed/len(tasks):.2f} hrs" + ) + # sort unscheduled tasks to the end of the list - tasks.sort(key=lambda x: x.scheduled_start_date if x.scheduled_start_date is not None else datetime.max.replace(tzinfo=tz.tzutc())) + tasks.sort( + key=lambda x: ( + x.scheduled_start_date + if x.scheduled_start_date is not None + else datetime.max.replace(tzinfo=tz.tzutc()) + ) + ) last_task_date = tasks[-1].scheduled_start_date - if last_task_date is None: # last task on todo list is not scheduled + if last_task_date is None: # last task on todo list is not scheduled print("Too many tasks on todo list. Not all are scheduled.") else: today = datetime.now(tz.tzutc()) @@ -300,101 +308,102 @@ def print_time_needed(): @app.command("finished") -def remove_finished_tasks_from_things(dry_run : bool = False): +def remove_finished_tasks_from_things(dry_run: bool = False): """ Complete finished reclaim tasks in things """ reclaim_things_uuids = reclaim_handler.get_reclaim_things_ids() - tasks_to_be_removed = [task for task in things_handler.get_all_uploaded_things_tasks() - if task["uuid"] not in reclaim_things_uuids] + tasks_to_be_removed = [ + task + for task in things_handler.get_all_uploaded_things_tasks() + if task["uuid"] not in reclaim_things_uuids + ] if not tasks_to_be_removed: print("Reclaim and Things are synced!") + return 0 else: - for task in tasks_to_be_removed: + for task in tasks_to_be_removed: print( f"Found completed task: { things_handler.full_name(things_task=task)}" ) if not dry_run: finish_task(task["uuid"]) + return len(tasks_to_be_removed) @app.command("tracking") -def sync_toggl_reclaim_tracking(since_days : Annotated[int, typer.Argument()] = 0): - toggl_time_entries = toggl_handler.get_time_entries_since(since_days = since_days) # end date is inclusive +def sync_toggl_reclaim_tracking(since_days: Annotated[int, typer.Argument()] = 0): + toggl_time_entries = toggl_handler.get_time_entries_since( + since_days=since_days + ) # end date is inclusive if toggl_time_entries is None: utils.pwarning(f"No tasks tracked in Toggl since {since_days} days") return - reclaim_time_entries = reclaim_handler.get_task_events_since(since_days = since_days) # end date is inclusive - reclaim_time_entries_dict = {utils.get_clean_time_entry_name(k) : list(g) for k,g in itertools.groupby(reclaim_time_entries, lambda r : r.name)} - non_existent_time_entries = [time_entry for time_entry in toggl_time_entries if time_entry.description not in reclaim_time_entries_dict.keys()] + reclaim_time_entries = reclaim_handler.get_task_events_since( + since_days=since_days + ) # end date is inclusive + reclaim_time_entries_dict = { + utils.get_clean_time_entry_name(k): list(g) + for k, g in itertools.groupby(reclaim_time_entries, lambda r: r.name) + } + non_existent_time_entries = [ + time_entry + for time_entry in toggl_time_entries + if time_entry.description not in reclaim_time_entries_dict.keys() + ] # add all existing mismatched_time_entries - time_entries_to_adjust : Dict[toggl_handler.TimeEntry, reclaim_handler.ReclaimTaskEvent] = {} + time_entries_to_adjust: Dict[ + toggl_handler.TimeEntry, reclaim_handler.ReclaimTaskEvent + ] = {} for toggl_time_entry in toggl_time_entries: if toggl_time_entry.description in reclaim_time_entries_dict.keys(): - nearest_entry = utils.nearest_time_entry(reclaim_time_entries_dict.get(toggl_time_entry.description), toggl_time_entry) #pyright: ignore + nearest_entry = utils.nearest_time_entry( + reclaim_time_entries_dict.get(toggl_time_entry.description), + toggl_time_entry, + ) # pyright: ignore if toggl_time_entry in time_entries_to_adjust: - time_entries_to_adjust[toggl_time_entry] = utils.nearest_time_entry([time_entries_to_adjust[toggl_time_entry], nearest_entry], toggl_time_entry) #pyright: ignore + time_entries_to_adjust[toggl_time_entry] = utils.nearest_time_entry( + [time_entries_to_adjust[toggl_time_entry], nearest_entry], + toggl_time_entry, + ) # pyright: ignore elif nearest_entry is not None: - time_entries_to_adjust[toggl_time_entry] = nearest_entry + time_entries_to_adjust[toggl_time_entry] = nearest_entry rprint(non_existent_time_entries) rprint(time_entries_to_adjust) return - if not mismatched_time_entries: - utils.pinfo("Toggl and Reclaim time entries are synced.") - else: - for time_entry in mismatched_time_entries: - name = time_entry.description - if not name: - raise ValueError("Toggl time entry has empty description") - reclaim_task = reclaim_handler.get_reclaim_task(name) - if not reclaim_task: - utils.pwarning(f"Couldn't find {time_entry.description} in Reclaim.") - continue - - toggl_start = toggl_handler.get_start_time(time_entry) - toggl_stop = toggl_handler.get_stop_time(time_entry) - - if toggl_start.tzinfo is None: - raise ValueError("Toggl start time is not timezone aware") - if toggl_stop.tzinfo is None: - raise ValueError("Toggl stop time is not timezone aware") - - - - reclaim_time_entry = reclaim_time_entries_dict[reclaim_task.name] - if reclaim_time_entry is None: - reclaim_handler.log_work_for_task(reclaim_task, start, stop) - else: - reclaim_handler.adjust_time_entry(reclaim_time_entry, start, stop) - utils.plogtime(start, stop, reclaim_task.name) - - @app.command("current") def display_current_task(): current_task = toggl_handler.get_current_time_entry() if current_task is None: utils.perror("No task is currently tracked in toggl") return - rprint(f"Current task: {current_task.description}\nStarted at:", - f"{toggl_handler.get_start_time(current_task) - .astimezone(tz.gettz()).strftime("%H:%M")}") + rprint( + f"Current task: {current_task.description}\nStarted at:", + f"{toggl_handler.get_start_time(current_task) + .astimezone(tz.gettz()).strftime("%H:%M")}", + ) @app.command("sync") -def sync_things_and_reclaim(dry_run : bool = False): +def sync_things_and_reclaim(dry_run: bool = False): """ Sync tasks between things and reclaim First updated all finished tasks in reclaim to completed in things Then upload all new tasks from things to reclaim """ utils.pinfo("Pulling from Reclaim") - remove_finished_tasks_from_things(dry_run) + removed_tasks = remove_finished_tasks_from_things(dry_run) rprint("---------------------------------------------") - time.sleep(1) # stop tool from uploading recently finished tasks + time_to_wait = 2 * removed_tasks + if time_to_wait > 0: + rprint(f"Waiting {time_to_wait}s for things to mark tasks as completed") + time.sleep( + time_to_wait + ) # wait for os.system call in things_handler.complete to take effect utils.pinfo("Pushing to Reclaim") upload_things_to_reclaim(dry_run) rprint("---------------------------------------------") diff --git a/things2reclaim/reclaim_handler.py b/things2reclaim/reclaim_handler.py index a51286c..1be4837 100644 --- a/things2reclaim/reclaim_handler.py +++ b/things2reclaim/reclaim_handler.py @@ -9,7 +9,7 @@ from reclaim_sdk.client import ReclaimClient from reclaim_sdk.models.task import ReclaimTask from reclaim_sdk.models.task_event import ReclaimTaskEvent -from deadline_status import DeadlineStatus +from deadline_status import DeadlineStatus import utils CONFIG_PATH = utils.get_project_root() / "things2reclaim/config/.reclaim.toml" @@ -21,25 +21,26 @@ with open(CONFIG_PATH, "rb") as f: RECLAIM_TOKEN = _config["reclaim_ai"]["token"] -THINGS_ID_PATTERN = "things_task:[a-zA-z0-9]+" +THINGS_ID_PATTERN = "things_task:[a-zA-z0-9]+" -things_id_pattern : Pattern[str] = re.compile(THINGS_ID_PATTERN) +things_id_pattern: Pattern[str] = re.compile(THINGS_ID_PATTERN) ReclaimClient(token=RECLAIM_TOKEN) -def get_reclaim_task(name : str) -> Optional[ReclaimTask]: - res = ReclaimTask.search(title = name) +def get_reclaim_task(name: str) -> Optional[ReclaimTask]: + res = ReclaimTask.search(title=name) if not res: return None else: return res[0] + def get_reclaim_tasks() -> List[ReclaimTask]: return ReclaimTask.search() -def get_reclaim_task_names(tasks : Optional[List[ReclaimTask]] = None): +def get_reclaim_task_names(tasks: Optional[List[ReclaimTask]] = None): if not tasks: tasks = get_reclaim_tasks() return [task.name for task in tasks] @@ -49,13 +50,19 @@ def filter_for_subject(subject, tasks): return [task for task in tasks if task.name.startswith(subject)] -def filter_for_deadline_status(cur_date : datetime, deadline_status : DeadlineStatus, tasks : List[ReclaimTask]): +def filter_for_deadline_status( + cur_date: datetime, deadline_status: DeadlineStatus, tasks: List[ReclaimTask] +): # [task for task in reclaim_tasks if task.due_date >= current_date] match deadline_status: case DeadlineStatus.FINE: - return [task for task in tasks if (task.due_date and task.due_date >= cur_date)] + return [ + task for task in tasks if (task.due_date and task.due_date >= cur_date) + ] case DeadlineStatus.OVERDUE: - return [task for task in tasks if (task.due_date and task.due_date < cur_date)] + return [ + task for task in tasks if (task.due_date and task.due_date < cur_date) + ] case DeadlineStatus.NONE: return [task for task in tasks if task.due_date is None] @@ -64,19 +71,26 @@ def get_project(task: ReclaimTask): return task.name.split(" ")[0] +def get_clean_time_entry_name(name: str): + return emoji.replace_emoji(name).lstrip() + + def is_task_time_entry(name: str): decoded_name = emoji.demojize(name) # task entries start either with a :thumbs_up: or a :check_mark_button: emoji - return decoded_name.startswith(":thumbs_up:") | decoded_name.startswith(":check_mark_button:") + return decoded_name.startswith(":thumbs_up:") | decoded_name.startswith( + ":check_mark_button:" + ) def get_task_events_since(since_days: int = 0) -> List[ReclaimTaskEvent]: date_now = datetime.now(tz.tzlocal()).date() date_since = date_now - timedelta(days=since_days) - date_end = date_now + timedelta(days = 1) # end date is exclusive + date_end = date_now + timedelta(days=1) # end date is exclusive events = ReclaimTaskEvent.search(date_since, date_end) return [event for event in events if is_task_time_entry(event.name)] + def get_events_date_range(from_date: date, to_date: date): return ReclaimTaskEvent.search(from_date, to_date) @@ -122,10 +136,13 @@ def finish_task(task: ReclaimTask): def get_things_id(task: ReclaimTask): things_id_match = things_id_pattern.match(task.description) if things_id_match is None: - raise ValueError(f"things id could not be found in description of reclaim task {task.name}") - things_id_tag : str = things_id_match.group() + raise ValueError( + f"things id could not be found in description of reclaim task {task.name}" + ) + things_id_tag: str = things_id_match.group() return things_id_tag.split(":")[1] -def get_reclaim_things_ids() -> List[str]: + +def get_reclaim_things_ids() -> List[str]: return [get_things_id(task) for task in ReclaimTask.search()] diff --git a/things2reclaim/things_handler.py b/things2reclaim/things_handler.py index ed9b3de..c64fa0e 100644 --- a/things2reclaim/things_handler.py +++ b/things2reclaim/things_handler.py @@ -32,8 +32,8 @@ def get_tasks_for_project(project) -> Dict | List[Dict]: return things.tasks(project=project["uuid"], type="to-do") -def is_equal_fullname(things_task : Dict, name : str): - return full_name(things_task=things_task) == name +def is_equal_fullname(things_task: Dict, name: str): + return full_name(things_task=things_task) == name def get_all_things_tasks() -> List: diff --git a/things2reclaim/toggl_handler.py b/things2reclaim/toggl_handler.py index e2650c3..98280ab 100644 --- a/things2reclaim/toggl_handler.py +++ b/things2reclaim/toggl_handler.py @@ -33,18 +33,19 @@ time_entry_editor = toggl_python.WorkspaceTimeEntries( def get_time_entry(time_entry_id: int) -> toggl_python.TimeEntry: return toggl_python.TimeEntries(auth=auth).retrieve(time_entry_id) + def delete_time_entry(time_entry_id: int) -> bool: return time_entry_editor.delete_timeentry(time_entry_id) -def get_start_time(time_entry : toggl_python.TimeEntry): + +def get_start_time(time_entry: toggl_python.TimeEntry): return time_entry.start() if callable(time_entry.start) else time_entry.start def get_stop_time(time_entry: toggl_python.TimeEntry): if time_entry.stop is None: - return get_start_time(time_entry) + timedelta(seconds=time_entry.duration) - else: - return time_entry.stop() if callable(time_entry.stop) else time_entry.stop + raise ValueError(f"TimeEntry {time_entry.description} is still running") + return time_entry.stop() if callable(time_entry.stop) else time_entry.stop def get_time_entries_date_range(from_date: date, to_date: date): @@ -115,11 +116,14 @@ def create_task_time_entry( duration=duration, ) - if start is not None: + if start is None: + start = datetime.now(tz.tzutc()) + else: if start.tzinfo is None: raise ValueError("start has to be timezone aware") start = start.astimezone(tz.tzutc()) - time_entry.start = start + + time_entry.start = start tag = get_approriate_tag(description) if tag: @@ -132,14 +136,21 @@ def start_task(description: str, project: str): time_entry_editor.create(create_task_time_entry(description, project)) +def stop_task(task: TimeEntry) -> TimeEntry | None: + if time_entry_editor.DETAIL_URL is None: + raise ValueError("DetailURL not set") + + url = time_entry_editor.BASE_URL.join( + time_entry_editor.DETAIL_URL.format(id=task.id) + ) + response = time_entry_editor.patch(url) + data = response.json() + if data: + return TimeEntry(**data) + + def stop_current_task(): cur_task = get_current_time_entry() if cur_task is None: raise RuntimeError("No time entry is currently running") - if time_entry_editor.DETAIL_URL is None: - raise ValueError("DetailURL not set") - url = time_entry_editor.BASE_URL.join( - time_entry_editor.DETAIL_URL.format(id=cur_task.id) - ) - - return time_entry_editor.patch(url) + return stop_task(cur_task) diff --git a/things2reclaim/utils.py b/things2reclaim/utils.py index 62c78da..385db78 100644 --- a/things2reclaim/utils.py +++ b/things2reclaim/utils.py @@ -16,7 +16,8 @@ TIME_PATTERN = ( ) pattern = re.compile(TIME_PATTERN) -T = TypeVar("T") # generic type +T = TypeVar("T") # generic type + def calculate_time_on_unit(tag_value: str) -> float: # This is a regex to match time in the format of 1h 30m @@ -39,18 +40,22 @@ def calculate_time_on_unit(tag_value: str) -> float: def get_start_time(toggl_time_entry: TimeEntry): - return toggl_time_entry.start() if callable(toggl_time_entry.start) else toggl_time_entry.start + return ( + toggl_time_entry.start() + if callable(toggl_time_entry.start) + else toggl_time_entry.start + ) def get_stop_time(time_entry: TimeEntry): if time_entry.stop is None: - return get_start_time(time_entry) + timedelta(seconds=time_entry.duration) + return get_start_time(time_entry) + timedelta(seconds=time_entry.duration) else: return time_entry.stop() if callable(time_entry.stop) else time_entry.stop -def get_clean_time_entry_name(name : str): - return emoji.replace_emoji(name).lstrip() +def get_clean_time_entry_name(name: str): + return emoji.replace_emoji(name).lstrip() def map_tag_values( @@ -90,17 +95,24 @@ def get_closest_match(name: str, candidates: Dict[str, T]) -> T | None: return candidates[ListPrompt.ask("Select a candidate", possible_candidates)] -def nearest_time_entry(items: Optional[List[ReclaimTaskEvent]], pivot: TimeEntry) -> Optional[ReclaimTaskEvent]: +def nearest_time_entry( + items: Optional[List[ReclaimTaskEvent]], pivot: TimeEntry +) -> Optional[ReclaimTaskEvent]: if not items: return None return min(items, key=lambda x: abs(x.start - get_start_time(pivot))) -def is_matching_time_entry(toggl_time_entry : Optional[TimeEntry], reclaim_time_entry : Optional[ReclaimTaskEvent]): +def is_matching_time_entry( + toggl_time_entry: Optional[TimeEntry], + reclaim_time_entry: Optional[ReclaimTaskEvent], +): if toggl_time_entry is None or reclaim_time_entry is None: print("One is none") return False - if toggl_time_entry.description != get_clean_time_entry_name(reclaim_time_entry.name): + if toggl_time_entry.description != get_clean_time_entry_name( + reclaim_time_entry.name + ): print(f"toggl title: {toggl_time_entry.description}") print(f"reclaim title: {get_clean_time_entry_name(reclaim_time_entry.name)}") return False @@ -129,6 +141,7 @@ def is_matching_time_entry(toggl_time_entry : Optional[TimeEntry], reclaim_time_ return False return True + def pinfo(msg: str): rprint(f"[bold white]{msg}[/bold white]") @@ -151,10 +164,11 @@ def plogtime(start_time: datetime, end_time: datetime, task_name: str): if end_time.tzinfo is None: raise ValueError("end_time has to be timezone aware.") - rprint( - (f"Logged work from {start_time.astimezone(local_zone).strftime(time_format)} " - f"to {end_time.astimezone(local_zone).strftime(time_format)} for {task_name}") + ( + f"Logged work from {start_time.astimezone(local_zone).strftime(time_format)} " + f"to {end_time.astimezone(local_zone).strftime(time_format)} for {task_name}" + ) )