From 3cb844f22c46bdcaf177ab2fcdc4852f3568c98d Mon Sep 17 00:00:00 2001 From: Chris Caron <lead2gold@gmail.com> Date: Mon, 14 Oct 2019 18:53:59 -0400 Subject: [PATCH] Add Apprise notification integration (#26868) * Added apprise notification component * flake-8 fixes; black formatting + import merged to 1 line * pylint issues resolved * added github name to manifest.json * import moved to top as per code review request * manifest formatting to avoid failing ci * .coveragerc updated to include apprise * removed block for written tests * more test coverage * formatting as per code review * tests converted to async style as per code review * increased coverage * bumped version of apprise to 0.8.1 * test that mocked entries are called * added tests for hass.service loading * support tags for those who identify the TARGET option * renamed variable as per code review * 'assert not' used instead of 'is False' * added period (in case linter isn't happy) --- CODEOWNERS | 1 + homeassistant/components/apprise/__init__.py | 1 + .../components/apprise/manifest.json | 12 ++ homeassistant/components/apprise/notify.py | 73 +++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/apprise/__init__.py | 1 + tests/components/apprise/test_notify.py | 148 ++++++++++++++++++ 8 files changed, 242 insertions(+) create mode 100644 homeassistant/components/apprise/__init__.py create mode 100644 homeassistant/components/apprise/manifest.json create mode 100644 homeassistant/components/apprise/notify.py create mode 100644 tests/components/apprise/__init__.py create mode 100644 tests/components/apprise/test_notify.py diff --git a/CODEOWNERS b/CODEOWNERS index ea50d24095c..8e52210cec7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -26,6 +26,7 @@ homeassistant/components/ambient_station/* @bachya homeassistant/components/androidtv/* @JeffLIrion homeassistant/components/apache_kafka/* @bachya homeassistant/components/api/* @home-assistant/core +homeassistant/components/apprise/* @caronc homeassistant/components/aprs/* @PhilRW homeassistant/components/arcam_fmj/* @elupus homeassistant/components/arduino/* @fabaff diff --git a/homeassistant/components/apprise/__init__.py b/homeassistant/components/apprise/__init__.py new file mode 100644 index 00000000000..6ffdaf690d9 --- /dev/null +++ b/homeassistant/components/apprise/__init__.py @@ -0,0 +1 @@ +"""The apprise component.""" diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json new file mode 100644 index 00000000000..3e971a96e7e --- /dev/null +++ b/homeassistant/components/apprise/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "apprise", + "name": "Apprise", + "documentation": "https://www.home-assistant.io/components/apprise", + "requirements": [ + "apprise==0.8.1" + ], + "dependencies": [], + "codeowners": [ + "@caronc" + ] +} diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py new file mode 100644 index 00000000000..662cc9c1ab6 --- /dev/null +++ b/homeassistant/components/apprise/notify.py @@ -0,0 +1,73 @@ +"""Apprise platform for notify component.""" +import logging + +import voluptuous as vol + +import apprise + +import homeassistant.helpers.config_validation as cv + +from homeassistant.components.notify import ( + ATTR_TARGET, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + PLATFORM_SCHEMA, + BaseNotificationService, +) + +_LOGGER = logging.getLogger(__name__) + +CONF_FILE = "config" +CONF_URL = "url" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_URL): vol.All(cv.ensure_list, [str]), + vol.Optional(CONF_FILE): cv.string, + } +) + + +def get_service(hass, config, discovery_info=None): + """Get the Apprise notification service.""" + + # Create our object + a_obj = apprise.Apprise() + + if config.get(CONF_FILE): + # Sourced from a Configuration File + a_config = apprise.AppriseConfig() + if not a_config.add(config[CONF_FILE]): + _LOGGER.error("Invalid Apprise config url provided") + return None + + if not a_obj.add(a_config): + _LOGGER.error("Invalid Apprise config url provided") + return None + + if config.get(CONF_URL): + # Ordered list of URLs + if not a_obj.add(config[CONF_URL]): + _LOGGER.error("Invalid Apprise URL(s) supplied") + return None + + return AppriseNotificationService(a_obj) + + +class AppriseNotificationService(BaseNotificationService): + """Implement the notification service for Apprise.""" + + def __init__(self, a_obj): + """Initialize the service.""" + self.apprise = a_obj + + def send_message(self, message="", **kwargs): + """Send a message to a specified target. + + If no target/tags are specified, then services are notified as is + However, if any tags are specified, then they will be applied + to the notification causing filtering (if set up that way). + """ + targets = kwargs.get(ATTR_TARGET) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + self.apprise.notify(body=message, title=title, tag=targets) diff --git a/requirements_all.txt b/requirements_all.txt index dbc94de9a1c..ef07a3f44b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -217,6 +217,9 @@ apcaccess==0.0.13 # homeassistant.components.apns apns2==0.3.0 +# homeassistant.components.apprise +apprise==0.8.1 + # homeassistant.components.aprs aprslib==0.6.46 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18970fcbac0..967943894fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -103,6 +103,9 @@ androidtv==0.0.30 # homeassistant.components.apns apns2==0.3.0 +# homeassistant.components.apprise +apprise==0.8.1 + # homeassistant.components.aprs aprslib==0.6.46 diff --git a/tests/components/apprise/__init__.py b/tests/components/apprise/__init__.py new file mode 100644 index 00000000000..ffebc35b4e1 --- /dev/null +++ b/tests/components/apprise/__init__.py @@ -0,0 +1 @@ +"""Tests for the apprise component.""" diff --git a/tests/components/apprise/test_notify.py b/tests/components/apprise/test_notify.py new file mode 100644 index 00000000000..237f99de676 --- /dev/null +++ b/tests/components/apprise/test_notify.py @@ -0,0 +1,148 @@ +"""The tests for the apprise notification platform.""" +from unittest.mock import patch +from unittest.mock import MagicMock + +from homeassistant.setup import async_setup_component + +BASE_COMPONENT = "notify" + + +async def test_apprise_config_load_fail01(hass): + """Test apprise configuration failures 1.""" + + config = { + BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": "/path/"} + } + + with patch("apprise.AppriseConfig.add", return_value=False): + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test that our service failed to load + assert not hass.services.has_service(BASE_COMPONENT, "test") + + +async def test_apprise_config_load_fail02(hass): + """Test apprise configuration failures 2.""" + + config = { + BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": "/path/"} + } + + with patch("apprise.Apprise.add", return_value=False): + with patch("apprise.AppriseConfig.add", return_value=True): + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test that our service failed to load + assert not hass.services.has_service(BASE_COMPONENT, "test") + + +async def test_apprise_config_load_okay(hass, tmp_path): + """Test apprise configuration failures.""" + + # Test cases where our URL is invalid + d = tmp_path / "apprise-config" + d.mkdir() + f = d / "apprise" + f.write_text("mailto://user:pass@example.com/") + + config = {BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": str(f)}} + + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Valid configuration was loaded; our service is good + assert hass.services.has_service(BASE_COMPONENT, "test") + + +async def test_apprise_url_load_fail(hass): + """Test apprise url failure.""" + + config = { + BASE_COMPONENT: { + "name": "test", + "platform": "apprise", + "url": "mailto://user:pass@example.com", + } + } + with patch("apprise.Apprise.add", return_value=False): + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test that our service failed to load + assert not hass.services.has_service(BASE_COMPONENT, "test") + + +async def test_apprise_notification(hass): + """Test apprise notification.""" + + config = { + BASE_COMPONENT: { + "name": "test", + "platform": "apprise", + "url": "mailto://user:pass@example.com", + } + } + + # Our Message + data = {"title": "Test Title", "message": "Test Message"} + + with patch("apprise.Apprise") as mock_apprise: + obj = MagicMock() + obj.add.return_value = True + obj.notify.return_value = True + mock_apprise.return_value = obj + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test the existance of our service + assert hass.services.has_service(BASE_COMPONENT, "test") + + # Test the call to our underlining notify() call + await hass.services.async_call(BASE_COMPONENT, "test", data) + await hass.async_block_till_done() + + # Validate calls were made under the hood correctly + obj.add.assert_called_once_with([config[BASE_COMPONENT]["url"]]) + obj.notify.assert_called_once_with( + **{"body": data["message"], "title": data["title"], "tag": None} + ) + + +async def test_apprise_notification_with_target(hass, tmp_path): + """Test apprise notification with a target.""" + + # Test cases where our URL is invalid + d = tmp_path / "apprise-config" + d.mkdir() + f = d / "apprise" + + # Write 2 config entries each assigned to different tags + f.write_text("devops=mailto://user:pass@example.com/\r\n") + f.write_text("system,alert=syslog://\r\n") + + config = {BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": str(f)}} + + # Our Message, only notify the services tagged with "devops" + data = {"title": "Test Title", "message": "Test Message", "target": ["devops"]} + + with patch("apprise.Apprise") as mock_apprise: + apprise_obj = MagicMock() + apprise_obj.add.return_value = True + apprise_obj.notify.return_value = True + mock_apprise.return_value = apprise_obj + assert await async_setup_component(hass, BASE_COMPONENT, config) + await hass.async_block_till_done() + + # Test the existance of our service + assert hass.services.has_service(BASE_COMPONENT, "test") + + # Test the call to our underlining notify() call + await hass.services.async_call(BASE_COMPONENT, "test", data) + await hass.async_block_till_done() + + # Validate calls were made under the hood correctly + apprise_obj.notify.assert_called_once_with( + **{"body": data["message"], "title": data["title"], "tag": data["target"]} + ) -- GitLab