diff --git a/.coveragerc b/.coveragerc index a7ce4d67c52..b0b9cc92fc4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -607,6 +607,7 @@ omit = homeassistant/components/sensehat/light.py homeassistant/components/sensehat/sensor.py homeassistant/components/sensibo/climate.py + homeassistant/components/sentry/__init__.py homeassistant/components/serial/sensor.py homeassistant/components/serial_pm/sensor.py homeassistant/components/sesame/lock.py diff --git a/CODEOWNERS b/CODEOWNERS index a8221632066..13c24bcbfd4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -281,6 +281,7 @@ homeassistant/components/scrape/* @fabaff homeassistant/components/script/* @home-assistant/core homeassistant/components/sense/* @kbickar homeassistant/components/sensibo/* @andrey-git +homeassistant/components/sentry/* @dcramer homeassistant/components/serial/* @fabaff homeassistant/components/seventeentrack/* @bachya homeassistant/components/shell_command/* @home-assistant/core diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 48ca96c7254..7ceedba5bd5 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -31,7 +31,7 @@ DATA_LOGGING = "logging" DEBUGGER_INTEGRATIONS = {"ptvsd"} CORE_INTEGRATIONS = ("homeassistant", "persistent_notification") -LOGGING_INTEGRATIONS = {"logger", "system_log"} +LOGGING_INTEGRATIONS = {"logger", "system_log", "sentry"} STAGE_1_INTEGRATIONS = { # To record data "recorder", diff --git a/homeassistant/components/sentry/.translations/en.json b/homeassistant/components/sentry/.translations/en.json new file mode 100644 index 00000000000..8d5042731ca --- /dev/null +++ b/homeassistant/components/sentry/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Sentry", + "step": { + "user": { + "title": "Sentry", + "description": "Enter your Sentry DSN" + } + }, + "error": { + "unknown": "Unexpected error", + "bad_dsn": "Invalid DSN" + }, + "abort": { + "already_configured": "Sentry is already configured" + } + } +} diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py new file mode 100644 index 00000000000..9c73de34af8 --- /dev/null +++ b/homeassistant/components/sentry/__init__.py @@ -0,0 +1,56 @@ +"""The sentry integration.""" +import logging + +import sentry_sdk +from sentry_sdk.integrations.logging import LoggingIntegration +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv + +from .const import CONF_DSN, CONF_ENVIRONMENT, DOMAIN + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Required(CONF_DSN): cv.string, CONF_ENVIRONMENT: cv.string} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Sentry component.""" + conf = config.get(DOMAIN) + if conf is not None: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Sentry from a config entry.""" + conf = entry.data + + hass.data[DOMAIN] = conf + + # https://docs.sentry.io/platforms/python/logging/ + sentry_logging = LoggingIntegration( + level=logging.INFO, # Capture info and above as breadcrumbs + event_level=logging.ERROR, # Send errors as events + ) + + sentry_sdk.init( + dsn=conf.get(CONF_DSN), + environment=conf.get(CONF_ENVIRONMENT), + integrations=[sentry_logging], + ) + + return True diff --git a/homeassistant/components/sentry/config_flow.py b/homeassistant/components/sentry/config_flow.py new file mode 100644 index 00000000000..194aa527d63 --- /dev/null +++ b/homeassistant/components/sentry/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for sentry integration.""" +import logging + +from sentry_sdk.utils import BadDsn, Dsn +import voluptuous as vol + +from homeassistant import config_entries, core + +from .const import CONF_DSN, DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_DSN): str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the DSN input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + # validate the dsn + Dsn(data["dsn"]) + + return {"title": "Sentry"} + + +class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Sentry config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a user config flow.""" + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + + return self.async_create_entry(title=info["title"], data=user_input) + except BadDsn: + errors["base"] = "bad_dsn" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) diff --git a/homeassistant/components/sentry/const.py b/homeassistant/components/sentry/const.py new file mode 100644 index 00000000000..1f799eb7fb9 --- /dev/null +++ b/homeassistant/components/sentry/const.py @@ -0,0 +1,6 @@ +"""Constants for the sentry integration.""" + +DOMAIN = "sentry" + +CONF_DSN = "dsn" +CONF_ENVIRONMENT = "environment" diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json new file mode 100644 index 00000000000..6a7428f7ea1 --- /dev/null +++ b/homeassistant/components/sentry/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "sentry", + "name": "Sentry", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sentry", + "requirements": ["sentry-sdk==0.13.5"], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": ["@dcramer"] +} diff --git a/homeassistant/components/sentry/strings.json b/homeassistant/components/sentry/strings.json new file mode 100644 index 00000000000..8d5042731ca --- /dev/null +++ b/homeassistant/components/sentry/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Sentry", + "step": { + "user": { + "title": "Sentry", + "description": "Enter your Sentry DSN" + } + }, + "error": { + "unknown": "Unexpected error", + "bad_dsn": "Invalid DSN" + }, + "abort": { + "already_configured": "Sentry is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 135fab2b746..d2a145d15b3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -65,6 +65,7 @@ FLOWS = [ "point", "ps4", "rainmachine", + "sentry", "simplisafe", "smartthings", "smhi", diff --git a/requirements_all.txt b/requirements_all.txt index 788f8abf54b..eb63f6cacdb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1803,6 +1803,9 @@ sense-hat==2.2.0 # homeassistant.components.sense sense_energy==0.7.0 +# homeassistant.components.sentry +sentry-sdk==0.13.5 + # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd197a44288..05c8ff7953a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -568,6 +568,9 @@ rxv==0.6.0 # homeassistant.components.samsungtv samsungctl[websocket]==0.7.1 +# homeassistant.components.sentry +sentry-sdk==0.13.5 + # homeassistant.components.simplisafe simplisafe-python==5.3.6 diff --git a/tests/components/sentry/__init__.py b/tests/components/sentry/__init__.py new file mode 100644 index 00000000000..d1e1818f927 --- /dev/null +++ b/tests/components/sentry/__init__.py @@ -0,0 +1 @@ +"""Tests for the sentry integration.""" diff --git a/tests/components/sentry/conftest.py b/tests/components/sentry/conftest.py new file mode 100644 index 00000000000..77da4119166 --- /dev/null +++ b/tests/components/sentry/conftest.py @@ -0,0 +1,18 @@ +"""Configuration for Sonos tests.""" +import pytest + +from homeassistant.components.sentry import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(): + """Create a mock config entry.""" + return MockConfigEntry(domain=DOMAIN, title="Sentry") + + +@pytest.fixture(name="config") +def config_fixture(): + """Create hass config fixture.""" + return {DOMAIN: {"dsn": "http://public@sentry.local/1"}} diff --git a/tests/components/sentry/test_config_flow.py b/tests/components/sentry/test_config_flow.py new file mode 100644 index 00000000000..7ce34c13f53 --- /dev/null +++ b/tests/components/sentry/test_config_flow.py @@ -0,0 +1,59 @@ +"""Test the sentry config flow.""" +from unittest.mock import patch + +from sentry_sdk.utils import BadDsn + +from homeassistant import config_entries, setup +from homeassistant.components.sentry.const import DOMAIN + +from tests.common import mock_coro + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.sentry.config_flow.validate_input", + return_value=mock_coro({"title": "Sentry"}), + ), patch( + "homeassistant.components.sentry.async_setup", return_value=mock_coro(True) + ) as mock_setup, patch( + "homeassistant.components.sentry.async_setup_entry", + return_value=mock_coro(True), + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"dsn": "http://public@sentry.local/1"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Sentry" + assert result2["data"] == { + "dsn": "http://public@sentry.local/1", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_bad_dsn(hass): + """Test we handle bad dsn error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.sentry.config_flow.validate_input", + side_effect=BadDsn, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"dsn": "foo"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "bad_dsn"}