From f3295ccb4abbd186102c441a5a8676e5db856b13 Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Thu, 21 Sep 2023 21:44:12 +0200 Subject: [PATCH 01/10] add support for apprise --- borgmatic/config/schema.yaml | 121 +++++++++++++++++++++++++++++++++++ borgmatic/hooks/apprise.py | 83 ++++++++++++++++++++++++ borgmatic/hooks/dispatch.py | 2 + borgmatic/hooks/monitor.py | 2 +- setup.py | 1 + 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 borgmatic/hooks/apprise.py diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index a2ba64e..9a37c88 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1306,6 +1306,127 @@ properties: example: - start - finish + apprise: + type: object + required: ['service_urls'] # TODO + additionalProperties: false + properties: + service_urls: + type: array + items: + type: string + description: | + List of Apprise service URLs to publish to. + example: + - "mastodon://accesskey/host/?visibility=direct" + - "pagerduty://A1BRTD4JD@TIiajkdnlazkcOXrIdevi7F/node01.local/drive_sda/" + start: + type: object + properties: + title: + type: string + description: | + Specify the message title. + example: Ping! + body: + type: string + description: | + Specify the message body. + exampe: Your backups have failed. + notification_type: + type: string + description: | + The Apprise message type. + enum: + - info + - success + - failure + - warning + example: + - failure + # tags: + # type: array + # items: + # type: string + # description: | + # One or more tags to filter which services to notify. + finish: + type: object + properties: + title: + type: string + description: | + Specify the message title. + example: Ping! + body: + type: string + description: | + Specify the message body. + exampe: Your backups have failed. + notification_type: + type: string + description: | + The Apprise message type. + enum: + - info + - success + - failure + - warning + example: + - failure + # tags: + # type: array + # items: + # type: string + # description: | + # One or more tags to filter which services to notify. + fail: + type: object + properties: + title: + type: string + description: | + Specify the message title. + example: Ping! + body: + type: string + description: | + Specify the message body. + exampe: Your backups have failed. + notification_type: + type: string + description: | + The Apprise message type. + enum: + - info + - success + - failure + - warning + example: + - failure + # tags: + # type: array + # items: + # type: string + # description: | + # One or more tags to filter which services to notify. + states: + type: array + items: + type: string + enum: + - start + - finish + - fail + uniqueItems: true + description: | + List of one or more monitoring states to ping for: "start", + "finish", and/or "fail". Defaults to pinging for failure + only. + example: + - start + - finish + healthchecks: type: object required: ['ping_url'] diff --git a/borgmatic/hooks/apprise.py b/borgmatic/hooks/apprise.py new file mode 100644 index 0000000..dbb4e47 --- /dev/null +++ b/borgmatic/hooks/apprise.py @@ -0,0 +1,83 @@ +import logging + +import apprise +from apprise import NotifyType, NotifyFormat + +logger = logging.getLogger(__name__) + + +def initialize_monitor( + ping_url, config, config_filename, monitoring_log_level, dry_run +): # pragma: no cover + ''' + No initialization is necessary for this monitor. + ''' + pass + + +def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run): + ''' + Ping the configured Apprise service URLs. + Use the given configuration filename in any log entries. + If this is a dry run, then don't actually ping anything. + ''' + run_states = hook_config.get('states', ['fail']) + + if state.name.lower() not in run_states: + return + + state_config = hook_config.get( + state.name.lower(), + { + 'title': f'A borgmatic {state.name} event happened', + 'body': f'A borgmatic {state.name} event happened', + 'notification_type': 'success', # TODO: default per state.name + # 'tag': ['borgmatic'], + }, + ) + + # TODO: Currently not very meaningful message. + # However, the Apprise service URLs can contain sensitive info. + dry_run_label = ' (dry run; not actually pinging)' if dry_run else '' + logger.info(f'{config_filename}: Pinging Apprise {dry_run_label}') + logger.debug(f'{config_filename}: Using Apprise ping') + + title = state_config.get('title', '') + body = state_config.get('body') + notify_type = state_config.get('notification_type', 'success') + + apobj = apprise.Apprise() + apobj.add(hook_config.get('service_urls')) + + if dry_run: + return + + result = apobj.notify( + title=title, + body=body, + body_format=NotifyFormat.TEXT, + notify_type=get_notify_type(notify_type) + ) + + if result is False: + logger.warning(f'{config_filename}: error sending some apprise notifications') + + +def get_notify_type(s): + if s == 'info': + return NotifyType.INFO + if s == 'success': + return NotifyType.SUCCESS + if s == 'warning': + return NotifyType.WARNING + if s == 'failure': + return NotifyType.FAILURE + + +def destroy_monitor( + ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run +): # pragma: no cover + ''' + No destruction is necessary for this monitor. + ''' + pass diff --git a/borgmatic/hooks/dispatch.py b/borgmatic/hooks/dispatch.py index 24793b5..d437c98 100644 --- a/borgmatic/hooks/dispatch.py +++ b/borgmatic/hooks/dispatch.py @@ -1,6 +1,7 @@ import logging from borgmatic.hooks import ( + apprise, cronhub, cronitor, healthchecks, @@ -17,6 +18,7 @@ from borgmatic.hooks import ( logger = logging.getLogger(__name__) HOOK_NAME_TO_MODULE = { + 'apprise': apprise, 'cronhub': cronhub, 'cronitor': cronitor, 'healthchecks': healthchecks, diff --git a/borgmatic/hooks/monitor.py b/borgmatic/hooks/monitor.py index 118639f..0cbfef4 100644 --- a/borgmatic/hooks/monitor.py +++ b/borgmatic/hooks/monitor.py @@ -1,6 +1,6 @@ from enum import Enum -MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki') +MONITOR_HOOK_NAMES = ('apprise', 'healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki') class State(Enum): diff --git a/setup.py b/setup.py index c9c16d3..9b7e698 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ setup( 'requests', 'ruamel.yaml>0.15.0,<0.18.0', 'setuptools', + 'apprise' ), include_package_data=True, python_requires='>=3.7', From 86011c841807238305f011ddb98a27871cb6ed24 Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Thu, 21 Sep 2023 22:02:45 +0200 Subject: [PATCH 02/10] default apprise notify type per borgmatic state --- borgmatic/hooks/apprise.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/borgmatic/hooks/apprise.py b/borgmatic/hooks/apprise.py index dbb4e47..53f46c9 100644 --- a/borgmatic/hooks/apprise.py +++ b/borgmatic/hooks/apprise.py @@ -31,7 +31,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev { 'title': f'A borgmatic {state.name} event happened', 'body': f'A borgmatic {state.name} event happened', - 'notification_type': 'success', # TODO: default per state.name + 'notification_type': default_notify_type(state.name.lower()), # 'tag': ['borgmatic'], }, ) @@ -74,6 +74,17 @@ def get_notify_type(s): return NotifyType.FAILURE +def default_notify_type(state): + if state == 'start': + return NotifyType.INFO + if state == 'finish': + return NotifyType.SUCCESS + if state == 'fail': + return NotifyType.FAILURE + if state == 'log': + return NotifyType.INFO + + def destroy_monitor( ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run ): # pragma: no cover From e7252c75454ac9727dbd240cf7cbbbf5c063ee35 Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Thu, 21 Sep 2023 22:06:08 +0200 Subject: [PATCH 03/10] remove comments about tags --- borgmatic/config/schema.yaml | 20 +------------------- borgmatic/hooks/apprise.py | 1 - 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 9a37c88..43754e5 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1308,7 +1308,7 @@ properties: - finish apprise: type: object - required: ['service_urls'] # TODO + required: ['service_urls'] additionalProperties: false properties: service_urls: @@ -1344,12 +1344,6 @@ properties: - warning example: - failure - # tags: - # type: array - # items: - # type: string - # description: | - # One or more tags to filter which services to notify. finish: type: object properties: @@ -1374,12 +1368,6 @@ properties: - warning example: - failure - # tags: - # type: array - # items: - # type: string - # description: | - # One or more tags to filter which services to notify. fail: type: object properties: @@ -1404,12 +1392,6 @@ properties: - warning example: - failure - # tags: - # type: array - # items: - # type: string - # description: | - # One or more tags to filter which services to notify. states: type: array items: diff --git a/borgmatic/hooks/apprise.py b/borgmatic/hooks/apprise.py index 53f46c9..c4ee6bc 100644 --- a/borgmatic/hooks/apprise.py +++ b/borgmatic/hooks/apprise.py @@ -32,7 +32,6 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev 'title': f'A borgmatic {state.name} event happened', 'body': f'A borgmatic {state.name} event happened', 'notification_type': default_notify_type(state.name.lower()), - # 'tag': ['borgmatic'], }, ) From 21f4266273ce94365bbecc560c2829b47d9f76ed Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Sat, 23 Sep 2023 21:11:15 +0200 Subject: [PATCH 04/10] incorporate PR review comments --- borgmatic/config/schema.yaml | 66 +++++++++++++----------------------- borgmatic/hooks/apprise.py | 60 ++++++++++++-------------------- setup.py | 3 ++ 3 files changed, 48 insertions(+), 81 deletions(-) diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 43754e5..7315a22 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1311,15 +1311,28 @@ properties: required: ['service_urls'] additionalProperties: false properties: - service_urls: - type: array - items: - type: string + services: + type: object + required: + - url + - label + properties: + url: + type: string + example: "mastodon://accesskey/host/?visibility=direct" + label: + type: string + example: mastodon description: | - List of Apprise service URLs to publish to. + A list of Apprise services to publish to with URLs and labels. + The labels are used for logging. + A full list of services and their configuration can be found at + https://github.com/caronc/apprise/wiki. example: - - "mastodon://accesskey/host/?visibility=direct" - - "pagerduty://A1BRTD4JD@TIiajkdnlazkcOXrIdevi7F/node01.local/drive_sda/" + - url: "slack://xoxb-1234-1234-4ddbaae6f3523ada2d/#backups" + label: slackbackups + - url: "matrixs://nuxref:abc123@matrix.example.com/#general/#backups" + label: matrix start: type: object properties: @@ -1332,18 +1345,7 @@ properties: type: string description: | Specify the message body. - exampe: Your backups have failed. - notification_type: - type: string - description: | - The Apprise message type. - enum: - - info - - success - - failure - - warning - example: - - failure + example: Starting backup process. finish: type: object properties: @@ -1356,18 +1358,7 @@ properties: type: string description: | Specify the message body. - exampe: Your backups have failed. - notification_type: - type: string - description: | - The Apprise message type. - enum: - - info - - success - - failure - - warning - example: - - failure + example: Backups successfully made. fail: type: object properties: @@ -1380,18 +1371,7 @@ properties: type: string description: | Specify the message body. - exampe: Your backups have failed. - notification_type: - type: string - description: | - The Apprise message type. - enum: - - info - - success - - failure - - warning - example: - - failure + example: Your backups have failed. states: type: array items: diff --git a/borgmatic/hooks/apprise.py b/borgmatic/hooks/apprise.py index c4ee6bc..184c75f 100644 --- a/borgmatic/hooks/apprise.py +++ b/borgmatic/hooks/apprise.py @@ -5,6 +5,13 @@ from apprise import NotifyType, NotifyFormat logger = logging.getLogger(__name__) +state_to_notify_type = { + 'start': NotifyType.INFO, + 'finish': NotifyType.SUCCESS, + 'fail': NotifyType.FAILURE, + 'log': NotifyType.INFO +} + def initialize_monitor( ping_url, config, config_filename, monitoring_log_level, dry_run @@ -17,9 +24,8 @@ def initialize_monitor( def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run): ''' - Ping the configured Apprise service URLs. - Use the given configuration filename in any log entries. - If this is a dry run, then don't actually ping anything. + Ping the configured Apprise service URLs. Use the given configuration filename in any log + entries. If this is a dry run, then don't actually ping anything. ''' run_states = hook_config.get('states', ['fail']) @@ -30,60 +36,38 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev state.name.lower(), { 'title': f'A borgmatic {state.name} event happened', - 'body': f'A borgmatic {state.name} event happened', - 'notification_type': default_notify_type(state.name.lower()), + 'body': f'A borgmatic {state.name} event happened' }, ) - # TODO: Currently not very meaningful message. - # However, the Apprise service URLs can contain sensitive info. - dry_run_label = ' (dry run; not actually pinging)' if dry_run else '' - logger.info(f'{config_filename}: Pinging Apprise {dry_run_label}') - logger.debug(f'{config_filename}: Using Apprise ping') + if not hook_config.get('services'): + logger.info(f'{config_filename}: No Apprise services to ping') + return + + dry_run_string = ' (dry run; not actually pinging)' if dry_run else '' + labels_string = ', '.join(map(lambda service: service['label'], hook_config.get('services'))) + logger.info(f'{config_filename}: Pinging Apprise services: {labels_string}{dry_run_string}') title = state_config.get('title', '') body = state_config.get('body') - notify_type = state_config.get('notification_type', 'success') + notify_type = state_to_notify_type[state.name.lower()] - apobj = apprise.Apprise() - apobj.add(hook_config.get('service_urls')) + apprise_object = apprise.Apprise() + apprise_object.add(map(lambda service: service['url'], hook_config.get('services'))) if dry_run: return - result = apobj.notify( + result = apprise_object.notify( title=title, body=body, body_format=NotifyFormat.TEXT, - notify_type=get_notify_type(notify_type) - ) + notify_type=notify_type) if result is False: logger.warning(f'{config_filename}: error sending some apprise notifications') -def get_notify_type(s): - if s == 'info': - return NotifyType.INFO - if s == 'success': - return NotifyType.SUCCESS - if s == 'warning': - return NotifyType.WARNING - if s == 'failure': - return NotifyType.FAILURE - - -def default_notify_type(state): - if state == 'start': - return NotifyType.INFO - if state == 'finish': - return NotifyType.SUCCESS - if state == 'fail': - return NotifyType.FAILURE - if state == 'log': - return NotifyType.INFO - - def destroy_monitor( ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run ): # pragma: no cover diff --git a/setup.py b/setup.py index 9b7e698..cf892b9 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,9 @@ setup( 'setuptools', 'apprise' ), + extra_require={ + "Apprise": ["apprise"] + }, include_package_data=True, python_requires='>=3.7', ) From 5a989826a10efad5771877006fce11626620f740 Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Sat, 23 Sep 2023 21:33:50 +0200 Subject: [PATCH 05/10] convert map to list for apprise function call fix apprise config schema remove apprise from required dependencies --- borgmatic/config/schema.yaml | 26 ++++++++++++++------------ borgmatic/hooks/apprise.py | 2 +- setup.py | 3 +-- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 7315a22..b7ca1bf 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1308,21 +1308,23 @@ properties: - finish apprise: type: object - required: ['service_urls'] + required: ['services'] additionalProperties: false properties: services: - type: object - required: - - url - - label - properties: - url: - type: string - example: "mastodon://accesskey/host/?visibility=direct" - label: - type: string - example: mastodon + type: array + items: + type: object + required: + - url + - label + properties: + url: + type: string + example: "mastodon://accesskey/host/?visibility=direct" + label: + type: string + example: mastodon description: | A list of Apprise services to publish to with URLs and labels. The labels are used for logging. diff --git a/borgmatic/hooks/apprise.py b/borgmatic/hooks/apprise.py index 184c75f..b70532b 100644 --- a/borgmatic/hooks/apprise.py +++ b/borgmatic/hooks/apprise.py @@ -53,7 +53,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev notify_type = state_to_notify_type[state.name.lower()] apprise_object = apprise.Apprise() - apprise_object.add(map(lambda service: service['url'], hook_config.get('services'))) + apprise_object.add(list(map(lambda service: service['url'], hook_config.get('services')))) if dry_run: return diff --git a/setup.py b/setup.py index cf892b9..b4757d4 100644 --- a/setup.py +++ b/setup.py @@ -34,8 +34,7 @@ setup( 'packaging', 'requests', 'ruamel.yaml>0.15.0,<0.18.0', - 'setuptools', - 'apprise' + 'setuptools' ), extra_require={ "Apprise": ["apprise"] From db8079b699d306805e3349fbea5feb6751a3e8c1 Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Mon, 25 Sep 2023 10:17:56 +0200 Subject: [PATCH 06/10] fix typo in setup.py handle if apprise cannot be imported --- borgmatic/hooks/apprise.py | 24 ++++++++++++++---------- setup.py | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/borgmatic/hooks/apprise.py b/borgmatic/hooks/apprise.py index b70532b..555600b 100644 --- a/borgmatic/hooks/apprise.py +++ b/borgmatic/hooks/apprise.py @@ -1,17 +1,7 @@ import logging -import apprise -from apprise import NotifyType, NotifyFormat - logger = logging.getLogger(__name__) -state_to_notify_type = { - 'start': NotifyType.INFO, - 'finish': NotifyType.SUCCESS, - 'fail': NotifyType.FAILURE, - 'log': NotifyType.INFO -} - def initialize_monitor( ping_url, config, config_filename, monitoring_log_level, dry_run @@ -27,6 +17,20 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev Ping the configured Apprise service URLs. Use the given configuration filename in any log entries. If this is a dry run, then don't actually ping anything. ''' + try: + import apprise + from apprise import NotifyType, NotifyFormat + except ImportError: + logger.warning('Unable to import Apprise in monitoring hook') + return + + state_to_notify_type = { + 'start': NotifyType.INFO, + 'finish': NotifyType.SUCCESS, + 'fail': NotifyType.FAILURE, + 'log': NotifyType.INFO + } + run_states = hook_config.get('states', ['fail']) if state.name.lower() not in run_states: diff --git a/setup.py b/setup.py index b4757d4..8902f76 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ setup( 'ruamel.yaml>0.15.0,<0.18.0', 'setuptools' ), - extra_require={ + extras_require={ "Apprise": ["apprise"] }, include_package_data=True, From a587e207f910c1a5281b9885803ded61a2128634 Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Mon, 25 Sep 2023 10:23:21 +0200 Subject: [PATCH 07/10] pin Apprise dependencies for test requirements --- test_requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test_requirements.txt b/test_requirements.txt index 56160dc..36792b9 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -31,3 +31,6 @@ toml==0.10.2; python_version >= '3.8' typed-ast; python_version >= '3.8' typing-extensions==4.5.0; python_version < '3.8' zipp==3.15.0; python_version < '3.8' +certifi==2022.9.24 +PyYAML==6.0 +Markdown==3.4.1 From eaa22be3db1f5810b21ee54fa8c52ea00501c89f Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Sun, 1 Oct 2023 17:00:20 +0200 Subject: [PATCH 08/10] fix PR comments --- borgmatic/config/schema.yaml | 34 +++++++++++++++++++++------------- borgmatic/hooks/apprise.py | 24 +++++++++++------------- setup.py | 6 ++---- test_requirements.txt | 11 ++++++----- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index b7ca1bf..f8a10d6 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1321,27 +1321,29 @@ properties: properties: url: type: string - example: "mastodon://accesskey/host/?visibility=direct" + example: "gotify://hostname/token" label: type: string example: mastodon description: | - A list of Apprise services to publish to with URLs and labels. - The labels are used for logging. - A full list of services and their configuration can be found at - https://github.com/caronc/apprise/wiki. + A list of Apprise services to publish to with URLs + and labels. The labels are used for logging. + A full list of services and their configuration can be found + at https://github.com/caronc/apprise/wiki. example: - - url: "slack://xoxb-1234-1234-4ddbaae6f3523ada2d/#backups" - label: slackbackups - - url: "matrixs://nuxref:abc123@matrix.example.com/#general/#backups" - label: matrix + - url: "kodi://user@hostname" + label: kodi + - url: "line://Token@User" + label: line start: type: object + required: ['body'] properties: title: type: string description: | - Specify the message title. + Specify the message title. If left unspecified, no + title is sent. example: Ping! body: type: string @@ -1350,11 +1352,13 @@ properties: example: Starting backup process. finish: type: object + required: ['body'] properties: title: type: string description: | - Specify the message title. + Specify the message title. If left unspecified, no + title is sent. example: Ping! body: type: string @@ -1363,11 +1367,13 @@ properties: example: Backups successfully made. fail: type: object + required: ['body'] properties: title: type: string description: | - Specify the message title. + Specify the message title. If left unspecified, no + title is sent. example: Ping! body: type: string @@ -1386,7 +1392,9 @@ properties: description: | List of one or more monitoring states to ping for: "start", "finish", and/or "fail". Defaults to pinging for failure - only. + only. For each selected state, corresponding configuration + for the message title and body should be given. If any is + left unspecified, a generic message is emitted instead. example: - start - finish diff --git a/borgmatic/hooks/apprise.py b/borgmatic/hooks/apprise.py index 555600b..5bb2ce4 100644 --- a/borgmatic/hooks/apprise.py +++ b/borgmatic/hooks/apprise.py @@ -1,4 +1,5 @@ import logging +import operator logger = logging.getLogger(__name__) @@ -19,7 +20,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev ''' try: import apprise - from apprise import NotifyType, NotifyFormat + from apprise import NotifyFormat, NotifyType except ImportError: logger.warning('Unable to import Apprise in monitoring hook') return @@ -28,7 +29,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev 'start': NotifyType.INFO, 'finish': NotifyType.SUCCESS, 'fail': NotifyType.FAILURE, - 'log': NotifyType.INFO + 'log': NotifyType.INFO, } run_states = hook_config.get('states', ['fail']) @@ -40,7 +41,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev state.name.lower(), { 'title': f'A borgmatic {state.name} event happened', - 'body': f'A borgmatic {state.name} event happened' + 'body': f'A borgmatic {state.name} event happened', }, ) @@ -49,27 +50,24 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev return dry_run_string = ' (dry run; not actually pinging)' if dry_run else '' - labels_string = ', '.join(map(lambda service: service['label'], hook_config.get('services'))) + labels_string = ', '.join(map(operator.itemgetter('label'), hook_config.get('services'))) logger.info(f'{config_filename}: Pinging Apprise services: {labels_string}{dry_run_string}') - title = state_config.get('title', '') - body = state_config.get('body') - notify_type = state_to_notify_type[state.name.lower()] - apprise_object = apprise.Apprise() - apprise_object.add(list(map(lambda service: service['url'], hook_config.get('services')))) + apprise_object.add(list(map(operator.itemgetter('url'), hook_config.get('services')))) if dry_run: return result = apprise_object.notify( - title=title, - body=body, + title=state_config.get('title', ''), + body=state_config.get('body'), body_format=NotifyFormat.TEXT, - notify_type=notify_type) + notify_type=state_to_notify_type[state.name.lower()], + ) if result is False: - logger.warning(f'{config_filename}: error sending some apprise notifications') + logger.warning(f'{config_filename}: Error sending some Apprise notifications') def destroy_monitor( diff --git a/setup.py b/setup.py index 8902f76..f1b7bc4 100644 --- a/setup.py +++ b/setup.py @@ -34,11 +34,9 @@ setup( 'packaging', 'requests', 'ruamel.yaml>0.15.0,<0.18.0', - 'setuptools' + 'setuptools', ), - extras_require={ - "Apprise": ["apprise"] - }, + extras_require={"Apprise": ["apprise"]}, include_package_data=True, python_requires='>=3.7', ) diff --git a/test_requirements.txt b/test_requirements.txt index 36792b9..138de96 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1,6 +1,8 @@ appdirs==1.4.4; python_version >= '3.8' +apprise==1.3.0 attrs==22.2.0; python_version >= '3.8' black==23.3.0; python_version >= '3.8' +certifi==2022.9.24 chardet==5.1.0 click==8.1.3; python_version >= '3.8' codespell==2.2.4 @@ -14,16 +16,18 @@ flexmock==0.11.3 idna==3.4 importlib_metadata==6.3.0; python_version < '3.8' isort==5.12.0 +jsonschema==4.17.3 +Markdown==3.4.1 mccabe==0.7.0 packaging==23.1 -pluggy==1.0.0 pathspec==0.11.1; python_version >= '3.8' +pluggy==1.0.0 py==1.11.0 pycodestyle==2.10.0 pyflakes==3.0.1 -jsonschema==4.17.3 pytest==7.3.0 pytest-cov==4.0.0 +PyYAML==6.0 regex; python_version >= '3.8' requests==2.31.0 ruamel.yaml>0.15.0,<0.18.0 @@ -31,6 +35,3 @@ toml==0.10.2; python_version >= '3.8' typed-ast; python_version >= '3.8' typing-extensions==4.5.0; python_version < '3.8' zipp==3.15.0; python_version < '3.8' -certifi==2022.9.24 -PyYAML==6.0 -Markdown==3.4.1 From 4763c323d0cb3de21ea91d16b3c5189e1a884bc3 Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Sun, 1 Oct 2023 18:52:20 +0200 Subject: [PATCH 09/10] add unit tests for apprise hook --- tests/unit/hooks/test_apprise.py | 197 +++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/unit/hooks/test_apprise.py diff --git a/tests/unit/hooks/test_apprise.py b/tests/unit/hooks/test_apprise.py new file mode 100644 index 0000000..65204c4 --- /dev/null +++ b/tests/unit/hooks/test_apprise.py @@ -0,0 +1,197 @@ +import apprise +from apprise import NotifyFormat, NotifyType +from flexmock import flexmock + +import borgmatic.hooks.monitor +from borgmatic.hooks import apprise as module + +topic = 'borgmatic-unit-testing' + + +def test_ping_monitor_adheres_dry_run(): + flexmock(apprise.Apprise).should_receive('notify').never() + + module.ping_monitor( + {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}]}, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=True, + ) + + +def test_ping_monitor_does_not_hit_with_no_states(): + flexmock(apprise.Apprise).should_receive('notify').never() + + module.ping_monitor( + {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], 'states': []}, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=True, + ) + + +def test_ping_monitor_hits_fail_by_default(): + flexmock(apprise.Apprise).should_receive('notify').once() + + for state in borgmatic.hooks.monitor.State: + module.ping_monitor( + {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}]}, + {}, + 'config.yaml', + state, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_hits_with_finish_default_config(): + flexmock(apprise.Apprise).should_receive('notify').with_args( + title='A borgmatic FINISH event happened', + body='A borgmatic FINISH event happened', + body_format=NotifyFormat.TEXT, + notify_type=NotifyType.SUCCESS, + ).once() + + module.ping_monitor( + {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], 'states': ['finish']}, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FINISH, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_hits_with_start_default_config(): + flexmock(apprise.Apprise).should_receive('notify').with_args( + title='A borgmatic START event happened', + body='A borgmatic START event happened', + body_format=NotifyFormat.TEXT, + notify_type=NotifyType.INFO, + ).once() + + module.ping_monitor( + {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], 'states': ['start']}, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.START, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_hits_with_fail_default_config(): + flexmock(apprise.Apprise).should_receive('notify').with_args( + title='A borgmatic FAIL event happened', + body='A borgmatic FAIL event happened', + body_format=NotifyFormat.TEXT, + notify_type=NotifyType.FAILURE, + ).once() + + module.ping_monitor( + {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], 'states': ['fail']}, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_hits_with_log_default_config(): + flexmock(apprise.Apprise).should_receive('notify').with_args( + title='A borgmatic LOG event happened', + body='A borgmatic LOG event happened', + body_format=NotifyFormat.TEXT, + notify_type=NotifyType.INFO, + ).once() + + module.ping_monitor( + {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], 'states': ['log']}, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.LOG, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_with_custom_message_title(): + flexmock(apprise.Apprise).should_receive('notify').with_args( + title='foo', + body='bar', + body_format=NotifyFormat.TEXT, + notify_type=NotifyType.FAILURE, + ).once() + + module.ping_monitor( + { + 'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], + 'states': ['fail'], + 'fail': {'title': 'foo', 'body': 'bar'}, + }, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_with_custom_message_body(): + flexmock(apprise.Apprise).should_receive('notify').with_args( + title='', + body='baz', + body_format=NotifyFormat.TEXT, + notify_type=NotifyType.FAILURE, + ).once() + + module.ping_monitor( + { + 'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], + 'states': ['fail'], + 'fail': {'body': 'baz'}, + }, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_multiple_services(): + flexmock(apprise.Apprise).should_receive('add').with_args( + [f'ntfys://{topic}', f'ntfy://{topic}'] + ).once() + + module.ping_monitor( + { + 'services': [ + {'url': f'ntfys://{topic}', 'label': 'ntfys'}, + {'url': f'ntfy://{topic}', 'label': 'ntfy'}, + ] + }, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_warning_for_no_services(): + flexmock(module.logger).should_receive('info').once() + + module.ping_monitor( + {'services': []}, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) From 7a9625cd4451c194943aebb356bf7a0a4b3de6a7 Mon Sep 17 00:00:00 2001 From: Pim Kunis Date: Wed, 4 Oct 2023 12:36:54 +0200 Subject: [PATCH 10/10] fix PR comments --- borgmatic/hooks/apprise.py | 2 +- tests/unit/hooks/test_apprise.py | 65 +++++++++++++++++++------------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/borgmatic/hooks/apprise.py b/borgmatic/hooks/apprise.py index 5bb2ce4..212bf7d 100644 --- a/borgmatic/hooks/apprise.py +++ b/borgmatic/hooks/apprise.py @@ -21,7 +21,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev try: import apprise from apprise import NotifyFormat, NotifyType - except ImportError: + except ImportError: # pragma: no cover logger.warning('Unable to import Apprise in monitoring hook') return diff --git a/tests/unit/hooks/test_apprise.py b/tests/unit/hooks/test_apprise.py index 65204c4..b11f769 100644 --- a/tests/unit/hooks/test_apprise.py +++ b/tests/unit/hooks/test_apprise.py @@ -5,14 +5,22 @@ from flexmock import flexmock import borgmatic.hooks.monitor from borgmatic.hooks import apprise as module -topic = 'borgmatic-unit-testing' +TOPIC = 'borgmatic-unit-testing' + + +def mock_apprise(): + apprise_mock = flexmock( + add=lambda servers: None, notify=lambda title, body, body_format, notify_type: None + ) + flexmock(apprise.Apprise).new_instances(apprise_mock) + return apprise_mock def test_ping_monitor_adheres_dry_run(): - flexmock(apprise.Apprise).should_receive('notify').never() + mock_apprise().should_receive('notify').never() module.ping_monitor( - {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}]}, + {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]}, {}, 'config.yaml', borgmatic.hooks.monitor.State.FAIL, @@ -22,10 +30,10 @@ def test_ping_monitor_adheres_dry_run(): def test_ping_monitor_does_not_hit_with_no_states(): - flexmock(apprise.Apprise).should_receive('notify').never() + mock_apprise().should_receive('notify').never() module.ping_monitor( - {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], 'states': []}, + {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': []}, {}, 'config.yaml', borgmatic.hooks.monitor.State.FAIL, @@ -35,11 +43,16 @@ def test_ping_monitor_does_not_hit_with_no_states(): def test_ping_monitor_hits_fail_by_default(): - flexmock(apprise.Apprise).should_receive('notify').once() + mock_apprise().should_receive('notify').with_args( + title='A borgmatic FAIL event happened', + body='A borgmatic FAIL event happened', + body_format=NotifyFormat.TEXT, + notify_type=NotifyType.FAILURE, + ).once() for state in borgmatic.hooks.monitor.State: module.ping_monitor( - {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}]}, + {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]}, {}, 'config.yaml', state, @@ -49,7 +62,7 @@ def test_ping_monitor_hits_fail_by_default(): def test_ping_monitor_hits_with_finish_default_config(): - flexmock(apprise.Apprise).should_receive('notify').with_args( + mock_apprise().should_receive('notify').with_args( title='A borgmatic FINISH event happened', body='A borgmatic FINISH event happened', body_format=NotifyFormat.TEXT, @@ -57,7 +70,7 @@ def test_ping_monitor_hits_with_finish_default_config(): ).once() module.ping_monitor( - {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], 'states': ['finish']}, + {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['finish']}, {}, 'config.yaml', borgmatic.hooks.monitor.State.FINISH, @@ -67,7 +80,7 @@ def test_ping_monitor_hits_with_finish_default_config(): def test_ping_monitor_hits_with_start_default_config(): - flexmock(apprise.Apprise).should_receive('notify').with_args( + mock_apprise().should_receive('notify').with_args( title='A borgmatic START event happened', body='A borgmatic START event happened', body_format=NotifyFormat.TEXT, @@ -75,7 +88,7 @@ def test_ping_monitor_hits_with_start_default_config(): ).once() module.ping_monitor( - {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], 'states': ['start']}, + {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['start']}, {}, 'config.yaml', borgmatic.hooks.monitor.State.START, @@ -85,7 +98,7 @@ def test_ping_monitor_hits_with_start_default_config(): def test_ping_monitor_hits_with_fail_default_config(): - flexmock(apprise.Apprise).should_receive('notify').with_args( + mock_apprise().should_receive('notify').with_args( title='A borgmatic FAIL event happened', body='A borgmatic FAIL event happened', body_format=NotifyFormat.TEXT, @@ -93,7 +106,7 @@ def test_ping_monitor_hits_with_fail_default_config(): ).once() module.ping_monitor( - {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], 'states': ['fail']}, + {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail']}, {}, 'config.yaml', borgmatic.hooks.monitor.State.FAIL, @@ -103,7 +116,7 @@ def test_ping_monitor_hits_with_fail_default_config(): def test_ping_monitor_hits_with_log_default_config(): - flexmock(apprise.Apprise).should_receive('notify').with_args( + mock_apprise().should_receive('notify').with_args( title='A borgmatic LOG event happened', body='A borgmatic LOG event happened', body_format=NotifyFormat.TEXT, @@ -111,7 +124,7 @@ def test_ping_monitor_hits_with_log_default_config(): ).once() module.ping_monitor( - {'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], 'states': ['log']}, + {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['log']}, {}, 'config.yaml', borgmatic.hooks.monitor.State.LOG, @@ -120,8 +133,8 @@ def test_ping_monitor_hits_with_log_default_config(): ) -def test_ping_monitor_with_custom_message_title(): - flexmock(apprise.Apprise).should_receive('notify').with_args( +def test_ping_monitor_passes_through_custom_message_title(): + mock_apprise().should_receive('notify').with_args( title='foo', body='bar', body_format=NotifyFormat.TEXT, @@ -130,7 +143,7 @@ def test_ping_monitor_with_custom_message_title(): module.ping_monitor( { - 'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], + 'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail'], 'fail': {'title': 'foo', 'body': 'bar'}, }, @@ -142,8 +155,8 @@ def test_ping_monitor_with_custom_message_title(): ) -def test_ping_monitor_with_custom_message_body(): - flexmock(apprise.Apprise).should_receive('notify').with_args( +def test_ping_monitor_passes_through_custom_message_body(): + mock_apprise().should_receive('notify').with_args( title='', body='baz', body_format=NotifyFormat.TEXT, @@ -152,7 +165,7 @@ def test_ping_monitor_with_custom_message_body(): module.ping_monitor( { - 'services': [{'url': f'ntfys://{topic}', 'label': 'ntfys'}], + 'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail'], 'fail': {'body': 'baz'}, }, @@ -164,16 +177,14 @@ def test_ping_monitor_with_custom_message_body(): ) -def test_ping_monitor_multiple_services(): - flexmock(apprise.Apprise).should_receive('add').with_args( - [f'ntfys://{topic}', f'ntfy://{topic}'] - ).once() +def test_ping_monitor_pings_multiple_services(): + mock_apprise().should_receive('add').with_args([f'ntfys://{TOPIC}', f'ntfy://{TOPIC}']).once() module.ping_monitor( { 'services': [ - {'url': f'ntfys://{topic}', 'label': 'ntfys'}, - {'url': f'ntfy://{topic}', 'label': 'ntfy'}, + {'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}, + {'url': f'ntfy://{TOPIC}', 'label': 'ntfy'}, ] }, {},