From ecf2ca20695b6858ae95f754821e9fbc1e0299bf Mon Sep 17 00:00:00 2001 From: Nikos Koukis Date: Sat, 20 Jan 2024 23:56:09 +0200 Subject: [PATCH] Introduce register_teardown_handler + bundle miscellaneous click options under @opts_miscellaneous --- syncall/app_utils.py | 35 ++++++++ syncall/cli.py | 29 ++++++- syncall/scripts/tw_caldav_sync.py | 33 ++------ syncall/scripts/tw_gcal_sync.py | 130 ++++++++++++++++-------------- syncall/scripts/tw_gtasks_sync.py | 87 ++++++++------------ 5 files changed, 174 insertions(+), 140 deletions(-) diff --git a/syncall/app_utils.py b/syncall/app_utils.py index 2f72d1b..5596f1f 100644 --- a/syncall/app_utils.py +++ b/syncall/app_utils.py @@ -4,6 +4,7 @@ `sys.exit()` to avoid dumping stack traces to the user. """ +import atexit import inspect import logging import os @@ -16,6 +17,7 @@ from urllib.parse import quote from bubop import ( + ExitHooks, PrefsManager, format_list, log_to_syslog, @@ -347,3 +349,36 @@ def app_log_to_syslog(): calling_file = Path(caller_frame[1]) fname = calling_file.stem log_to_syslog(name=fname) + + +def register_teardown_handler( + pdb_on_error: bool, inform_about_config: bool, combination_name: str, verbose: int +) -> ExitHooks: + """Shortcut for registering the teardown logic in a top-level sync application. + + We're explicilty not catching/reporting exceptions if the pdb_on_error argument is set + since the developer is meaning to get a prompt and debug any exception that arises. + """ + hooks: ExitHooks = ExitHooks() + + def teardown(): + if hooks.exception is not None: + if hooks.exception.__class__ is KeyboardInterrupt: + logger.error("C-c pressed, exiting...") + else: + report_toplevel_exception(is_verbose=verbose >= 1) + return 1 + + if inform_about_config: + inform_about_combination_name_usage(combination_name) + + if pdb_on_error: + logger.warning( + "pdb_on_error is enabled. Disabling exit hooks / not taking actions at the end " + "of the run." + ) + else: + hooks.register() + atexit.register(teardown) + + return hooks diff --git a/syncall/cli.py b/syncall/cli.py index ea00ab5..d0b4787 100644 --- a/syncall/cli.py +++ b/syncall/cli.py @@ -8,6 +8,7 @@ import click +from syncall import __version__ from syncall.app_utils import name_to_resolution_strategy_type from syncall.constants import COMBINATION_FLAGS from syncall.pdb_cli_utils import run_pdb_on_error as _run_pdb_on_error @@ -121,7 +122,7 @@ def opt_tw_all_tasks(): "--taskwarrior-all-tasks", "tw_sync_all_tasks", is_flag=True, - help="Sync all taskwarrior tasks [potentially very slow]", + help="Sync all taskwarrior tasks (potentially very slow)", ) @@ -370,6 +371,32 @@ def opt_filename_extension(): # general options ----------------------------------------------------------------------------- +def opts_miscellaneous(side_A_name: str, side_B_name: str): + def decorator(f): + for d in reversed( + [ + (opt_list_resolution_strategies,), + (opt_resolution_strategy,), + ( + click.version_option, + __version__, + ), + (opt_pdb_on_error,), + (opt_list_combinations, side_A_name, side_B_name), + (opt_combination, side_A_name, side_B_name), + (opt_custom_combination_savename, side_A_name, side_B_name), + ] + ): + fn = d[0] + fn_args = d[1:] + f = fn(*fn_args)(f) # type: ignore + + f = click.option("-v", "--verbose", count=True)(f) + return f + + return decorator + + def opt_default_duration_event_mins(): return click.option( "--default-event-duration-mins", diff --git a/syncall/scripts/tw_caldav_sync.py b/syncall/scripts/tw_caldav_sync.py index 3799497..003d276 100644 --- a/syncall/scripts/tw_caldav_sync.py +++ b/syncall/scripts/tw_caldav_sync.py @@ -1,4 +1,3 @@ -import atexit import datetime import os import subprocess @@ -7,7 +6,6 @@ import caldav import click from bubop import ( - ExitHooks, check_optional_mutually_exclusive, check_required_mutually_exclusive, format_dict, @@ -16,7 +14,7 @@ ) from syncall import inform_about_app_extras -from syncall.app_utils import app_log_to_syslog, error_and_exit +from syncall.app_utils import app_log_to_syslog, error_and_exit, register_teardown_handler from syncall.cli import ( opt_caldav_calendar, opt_caldav_passwd_cmd, @@ -42,7 +40,6 @@ fetch_app_configuration, fetch_from_pass_manager, get_resolution_strategy, - inform_about_combination_name_usage, list_named_combinations, opt_combination, opt_custom_combination_savename, @@ -50,7 +47,6 @@ opt_resolution_strategy, opt_tw_project, opt_tw_tags, - report_toplevel_exception, ) @@ -213,27 +209,12 @@ def main( caldav_side = CaldavSide(client=client, calendar_name=caldav_calendar) # teardown function and exception handling ------------------------------------------------ - hooks: ExitHooks = ExitHooks() - - def teardown(): - if hooks.exception is not None: - if hooks.exception.__class__ is KeyboardInterrupt: - logger.error("C-c pressed, exiting...") - else: - report_toplevel_exception(is_verbose=verbose >= 1) - return 1 - - if inform_about_config: - inform_about_combination_name_usage(combination_name) - - if pdb_on_error: - logger.warning( - "pdb_on_error is enabled. Disabling exit hooks / not taking actions at the end " - "of the run." - ) - else: - hooks.register() - atexit.register(teardown) + register_teardown_handler( + pdb_on_error=pdb_on_error, + inform_about_config=inform_about_config, + combination_name=combination_name, + verbose=verbose, + ) # sync ------------------------------------------------------------------------------------ with Aggregator( diff --git a/syncall/scripts/tw_gcal_sync.py b/syncall/scripts/tw_gcal_sync.py index 76edf5c..83510f8 100644 --- a/syncall/scripts/tw_gcal_sync.py +++ b/syncall/scripts/tw_gcal_sync.py @@ -3,10 +3,16 @@ from typing import List import click -from bubop import check_optional_mutually_exclusive, format_dict, logger, loguru_tqdm_sink +from bubop import ( + check_optional_mutually_exclusive, + check_required_mutually_exclusive, + format_dict, + logger, + loguru_tqdm_sink, +) from syncall import inform_about_app_extras -from syncall.app_utils import app_log_to_syslog +from syncall.app_utils import app_log_to_syslog, register_teardown_handler try: from syncall import GCalSide, TaskWarriorSide @@ -21,23 +27,14 @@ convert_tw_to_gcal, fetch_app_configuration, get_resolution_strategy, - inform_about_combination_name_usage, - list_named_combinations, - report_toplevel_exception, ) from syncall.cli import ( - opt_combination, - opt_custom_combination_savename, opt_default_duration_event_mins, opt_gcal_calendar, opt_google_oauth_port, opt_google_secret_override, - opt_list_combinations, - opt_list_resolution_strategies, - opt_prefer_scheduled_date, - opt_resolution_strategy, - opt_tw_project, - opt_tw_tags, + opts_miscellaneous, + opts_tw_filtering, ) @@ -47,32 +44,30 @@ @opt_google_secret_override() @opt_google_oauth_port() # taskwarrior options ------------------------------------------------------------------------- -@opt_tw_tags() -@opt_tw_project() +@opts_tw_filtering() # misc options -------------------------------------------------------------------------------- -@opt_list_combinations("TW", "Google Calendar") -@opt_list_resolution_strategies() -@opt_resolution_strategy() -@opt_combination("TW", "Google Calendar") -@opt_custom_combination_savename("TW", "Google Calendar") -@opt_prefer_scheduled_date() @opt_default_duration_event_mins() @click.option("-v", "--verbose", count=True) @click.version_option(__version__) +@opts_miscellaneous(side_A_name="TW", side_B_name="Google Tasks") def main( gcal_calendar: str, google_secret: str, oauth_port: int, + tw_filter: str, tw_tags: List[str], tw_project: str, + tw_only_modified_last_X_days: str, + tw_sync_all_tasks: bool, + prefer_scheduled_date: bool, resolution_strategy: str, verbose: int, combination_name: str, custom_combination_savename: str, do_list_combinations: bool, list_resolution_strategies: bool, # type: ignore - prefer_scheduled_date: bool, default_event_duration_mins: int, + pdb_on_error: bool, ): """Synchronize calendars from your Google Calendar with filters from Taskwarrior. @@ -91,15 +86,27 @@ def main( # cli validation -------------------------------------------------------------------------- check_optional_mutually_exclusive(combination_name, custom_combination_savename) - combination_of_tw_project_tags_and_gcal_calendar = any( + + tw_filter_li = [ + t + for t in [ + tw_filter, + tw_only_modified_last_X_days, + ] + if t + ] + + combination_of_tw_filters_and_gcal_calendar = any( [ - tw_project, + tw_filter_li, tw_tags, + tw_project, + tw_sync_all_tasks, gcal_calendar, ] ) check_optional_mutually_exclusive( - combination_name, combination_of_tw_project_tags_and_gcal_calendar + combination_name, combination_of_tw_filters_and_gcal_calendar ) # existing combination name is provided --------------------------------------------------- @@ -107,6 +114,7 @@ def main( app_config = fetch_app_configuration( config_fname="tw_gcal_configs", combination=combination_name ) + tw_filter_li = app_config["tw_filter_li"] tw_tags = app_config["tw_tags"] tw_project = app_config["tw_project"] gcal_calendar = app_config["gcal_calendar"] @@ -117,6 +125,7 @@ def main( combination_name = cache_or_reuse_cached_combination( config_args={ "gcal_calendar": gcal_calendar, + "tw_filter_li": tw_filter_li, "tw_project": tw_project, "tw_tags": tw_tags, }, @@ -124,16 +133,15 @@ def main( custom_combination_savename=custom_combination_savename, ) - # at least one of tw_tags, tw_project should be set --------------------------------------- - if not tw_tags and not tw_project: - logger.error( - "You have to provide at least one valid tag or a valid project ID to use for the" - " synchronization. You can do so either via CLI arguments or by specifying an" - " existing saved combination" - ) - sys.exit(1) - # more checks ----------------------------------------------------------------------------- + combination_of_tw_related_options = any([tw_filter_li, tw_tags, tw_project]) + check_required_mutually_exclusive( + tw_sync_all_tasks, + combination_of_tw_related_options, + "sync_all_tw_tasks", + "combination of specific TW-related options", + ) + if gcal_calendar is None: logger.error( "You have to provide the name of a Google Calendar calendar to synchronize events" @@ -147,8 +155,10 @@ def main( format_dict( header="Configuration", items={ + "TW Filter": " ".join(tw_filter_li), "TW Tags": tw_tags, "TW Project": tw_project, + "TW Sync All Tasks": tw_sync_all_tasks, "Google Calendar": gcal_calendar, "Prefer scheduled dates": prefer_scheduled_date, }, @@ -158,12 +168,22 @@ def main( ) # initialize sides ------------------------------------------------------------------------ - tw_side = TaskWarriorSide(tags=tw_tags, project=tw_project) + tw_side = TaskWarriorSide( + tw_filter=" ".join(tw_filter_li), tags=tw_tags, project=tw_project + ) gcal_side = GCalSide( calendar_summary=gcal_calendar, oauth_port=oauth_port, client_secret=google_secret ) + # teardown function and exception handling ------------------------------------------------ + register_teardown_handler( + pdb_on_error=pdb_on_error, + inform_about_config=inform_about_config, + combination_name=combination_name, + verbose=verbose, + ) + # take extra arguments into account ------------------------------------------------------- def convert_B_to_A(*args, **kargs): return convert_tw_to_gcal( @@ -185,31 +205,21 @@ def convert_A_to_B(*args, **kargs): convert_A_to_B.__doc__ = convert_gcal_to_tw.__doc__ # sync ------------------------------------------------------------------------------------ - try: - with Aggregator( - side_A=gcal_side, - side_B=tw_side, - converter_B_to_A=convert_B_to_A, - converter_A_to_B=convert_A_to_B, - resolution_strategy=get_resolution_strategy( - resolution_strategy, side_A_type=type(gcal_side), side_B_type=type(tw_side) - ), - config_fname=combination_name, - ignore_keys=( - (), - (), - ), - ) as aggregator: - aggregator.sync() - except KeyboardInterrupt: - logger.error("Exiting...") - return 1 - except: - report_toplevel_exception(is_verbose=verbose >= 1) - return 1 - - if inform_about_config: - inform_about_combination_name_usage(combination_name) + with Aggregator( + side_A=gcal_side, + side_B=tw_side, + converter_B_to_A=convert_B_to_A, + converter_A_to_B=convert_A_to_B, + resolution_strategy=get_resolution_strategy( + resolution_strategy, side_A_type=type(gcal_side), side_B_type=type(tw_side) + ), + config_fname=combination_name, + ignore_keys=( + (), + (), + ), + ) as aggregator: + aggregator.sync() return 0 diff --git a/syncall/scripts/tw_gtasks_sync.py b/syncall/scripts/tw_gtasks_sync.py index 3b48d2d..7dfcf74 100644 --- a/syncall/scripts/tw_gtasks_sync.py +++ b/syncall/scripts/tw_gtasks_sync.py @@ -6,12 +6,12 @@ check_optional_mutually_exclusive, check_required_mutually_exclusive, format_dict, - log_to_syslog, logger, loguru_tqdm_sink, ) from syncall import inform_about_app_extras +from syncall.app_utils import app_log_to_syslog, register_teardown_handler try: from syncall import GTasksSide, TaskWarriorSide @@ -20,46 +20,28 @@ from syncall import ( Aggregator, - __version__, cache_or_reuse_cached_combination, convert_gtask_to_tw, convert_tw_to_gtask, fetch_app_configuration, get_resolution_strategy, - inform_about_combination_name_usage, list_named_combinations, - report_toplevel_exception, ) from syncall.cli import ( - opt_combination, - opt_custom_combination_savename, opt_google_oauth_port, opt_google_secret_override, opt_gtasks_list, - opt_list_combinations, - opt_list_resolution_strategies, - opt_pdb_on_error, - opt_resolution_strategy, + opts_miscellaneous, opts_tw_filtering, ) @click.command() -# google tasks options --------------------------------------------------------------------- @opt_gtasks_list() @opt_google_secret_override() @opt_google_oauth_port() -# taskwarrior options ------------------------------------------------------------------------- @opts_tw_filtering() -# misc options -------------------------------------------------------------------------------- -@opt_list_combinations("TW", "Google Tasks") -@opt_list_resolution_strategies() -@opt_resolution_strategy() -@opt_combination("TW", "Google Tasks") -@opt_custom_combination_savename("TW", "Google Tasks") -@click.option("-v", "--verbose", count=True) -@click.version_option(__version__) -@opt_pdb_on_error() +@opts_miscellaneous(side_A_name="TW", side_B_name="Google Tasks") def main( gtasks_list: str, google_secret: str, @@ -75,7 +57,7 @@ def main( combination_name: str, custom_combination_savename: str, do_list_combinations: bool, - list_resolution_strategies: bool, # type: ignore + list_resolution_strategies: bool, pdb_on_error: bool, ): """Synchronize lists from your Google Tasks with filters from Taskwarrior. @@ -86,7 +68,7 @@ def main( """ # setup logger ---------------------------------------------------------------------------- loguru_tqdm_sink(verbosity=verbose) - log_to_syslog(name="tw_gtasks_sync") + app_log_to_syslog() logger.debug("Initialising...") inform_about_config = False @@ -95,6 +77,8 @@ def main( return 0 # cli validation -------------------------------------------------------------------------- + check_optional_mutually_exclusive(combination_name, custom_combination_savename) + tw_filter_li = [ t for t in [ @@ -104,8 +88,7 @@ def main( if t ] - check_optional_mutually_exclusive(combination_name, custom_combination_savename) - combination_of_tw_filter_and_gtasks_list = any( + combination_of_tw_filters_and_gtasks_list = any( [ tw_filter_li, tw_tags, @@ -115,7 +98,7 @@ def main( ] ) check_optional_mutually_exclusive( - combination_name, combination_of_tw_filter_and_gtasks_list + combination_name, combination_of_tw_filters_and_gtasks_list ) # existing combination name is provided --------------------------------------------------- @@ -135,8 +118,8 @@ def main( config_args={ "gtasks_list": gtasks_list, "tw_filter_li": tw_filter_li, - "tw_tags": tw_tags, "tw_project": tw_project, + "tw_tags": tw_tags, }, config_fname="tw_gtasks_configs", custom_combination_savename=custom_combination_savename, @@ -184,10 +167,18 @@ def main( tw_filter=" ".join(tw_filter_li), tags=tw_tags, project=tw_project ) - gtask_side = GTasksSide( + gtasks_side = GTasksSide( task_list_title=gtasks_list, oauth_port=oauth_port, client_secret=google_secret ) + # teardown function and exception handling ------------------------------------------------ + register_teardown_handler( + pdb_on_error=pdb_on_error, + inform_about_config=inform_about_config, + combination_name=combination_name, + verbose=verbose, + ) + # take extra arguments into account ------------------------------------------------------- def convert_B_to_A(*args, **kargs): return convert_tw_to_gtask( @@ -207,31 +198,21 @@ def convert_A_to_B(*args, **kargs): convert_A_to_B.__doc__ = convert_gtask_to_tw.__doc__ # sync ------------------------------------------------------------------------------------ - try: - with Aggregator( - side_A=gtask_side, - side_B=tw_side, - converter_B_to_A=convert_B_to_A, - converter_A_to_B=convert_A_to_B, - resolution_strategy=get_resolution_strategy( - resolution_strategy, side_A_type=type(gtask_side), side_B_type=type(tw_side) - ), - config_fname=combination_name, - ignore_keys=( - (), - (), - ), - ) as aggregator: - aggregator.sync() - except KeyboardInterrupt: - logger.error("Exiting...") - return 1 - except: - report_toplevel_exception(is_verbose=verbose >= 1) - return 1 - - if inform_about_config: - inform_about_combination_name_usage(combination_name) + with Aggregator( + side_A=gtasks_side, + side_B=tw_side, + converter_B_to_A=convert_B_to_A, + converter_A_to_B=convert_A_to_B, + resolution_strategy=get_resolution_strategy( + resolution_strategy, side_A_type=type(gtasks_side), side_B_type=type(tw_side) + ), + config_fname=combination_name, + ignore_keys=( + (), + (), + ), + ) as aggregator: + aggregator.sync() return 0