diff --git a/homeassistant/components/prowl/config_flow.py b/homeassistant/components/prowl/config_flow.py index cea3ee6e1066b1..6b8c450f42f937 100644 --- a/homeassistant/components/prowl/config_flow.py +++ b/homeassistant/components/prowl/config_flow.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN from .helpers import async_verify_key @@ -55,6 +56,30 @@ async def async_step_user( errors=errors, ) + async def async_step_import(self, config: dict[str, Any]) -> ConfigFlowResult: + """Handle import from legacy YAML.""" + api_key = config[CONF_API_KEY] + self._async_abort_entries_match({CONF_API_KEY: api_key}) + + errors = await self._validate_api_key(api_key) + if not errors: + return self.async_create_entry( + title=config[CONF_NAME], + data={ + CONF_API_KEY: api_key, + }, + ) + async_create_issue( + self.hass, + DOMAIN, + "migrate_fail_prowl", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="prowl_yaml_migration_fail", + ) + return self.async_abort(reason="invalid_api_key") + async def _validate_api_key(self, api_key: str) -> dict[str, str]: """Validate the provided API key.""" ret = {} diff --git a/homeassistant/components/prowl/helpers.py b/homeassistant/components/prowl/helpers.py index b567c9ffc04f20..59f2e0622ef838 100644 --- a/homeassistant/components/prowl/helpers.py +++ b/homeassistant/components/prowl/helpers.py @@ -15,7 +15,5 @@ async def async_verify_key(hass: HomeAssistant, api_key: str) -> bool: async with asyncio.timeout(10): await prowl.verify_key() return True - except prowlpy.APIError as ex: - if str(ex).startswith("Invalid API key"): - return False - raise + except prowlpy.InvalidAPIKeyError: + return False diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index d013480417ac34..91978ea7b882c3 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -17,16 +17,20 @@ PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, BaseNotificationService, NotifyEntity, + migrate_notify_issue, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) @@ -38,6 +42,22 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> ProwlNotificationService: """Get the Prowl notification service.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2026.04.01", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="prowl_yaml_deprecated", + ) + return ProwlNotificationService(hass, config[CONF_API_KEY], get_async_client(hass)) @@ -68,6 +88,8 @@ def __init__( async def async_send_message(self, message: str, **kwargs: Any) -> None: """Send the message to the user.""" + migrate_notify_issue(self._hass, DOMAIN, DOMAIN, "2026.04.01", DOMAIN) + data = kwargs.get(ATTR_DATA, {}) if data is None: data = {} @@ -84,15 +106,15 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None: except TimeoutError as ex: _LOGGER.error("Timeout accessing Prowl API") raise HomeAssistantError("Timeout accessing Prowl API") from ex + except prowlpy.InvalidAPIKeyError as ex: + _LOGGER.error("Invalid API key for Prowl service") + raise HomeAssistantError("Invalid API key for Prowl service") from ex + except prowlpy.RateLimitExceededError as ex: + _LOGGER.error("Prowl returned: exceeded rate limit") + raise HomeAssistantError( + "Prowl service reported: exceeded rate limit" + ) from ex except prowlpy.APIError as ex: - if str(ex).startswith("Invalid API key"): - _LOGGER.error("Invalid API key for Prowl service") - raise HomeAssistantError("Invalid API key for Prowl service") from ex - if str(ex).startswith("Not accepted"): - _LOGGER.error("Prowl returned: exceeded rate limit") - raise HomeAssistantError( - "Prowl service reported: exceeded rate limit" - ) from ex _LOGGER.error("Unexpected error when calling Prowl API: %s", str(ex)) raise HomeAssistantError("Unexpected error when calling Prowl API") from ex @@ -131,14 +153,14 @@ async def async_send_message(self, message: str, title: str | None = None) -> No except TimeoutError as ex: _LOGGER.error("Timeout accessing Prowl API") raise HomeAssistantError("Timeout accessing Prowl API") from ex + except prowlpy.InvalidAPIKeyError as ex: + _LOGGER.error("Invalid API key for Prowl service") + raise HomeAssistantError("Invalid API key for Prowl service") from ex + except prowlpy.RateLimitExceededError as ex: + _LOGGER.error("Prowl returned: exceeded rate limit") + raise HomeAssistantError( + "Prowl service reported: exceeded rate limit" + ) from ex except prowlpy.APIError as ex: - if str(ex).startswith("Invalid API key"): - _LOGGER.error("Invalid API key for Prowl service") - raise HomeAssistantError("Invalid API key for Prowl service") from ex - if str(ex).startswith("Not accepted"): - _LOGGER.error("Prowl returned: exceeded rate limit") - raise HomeAssistantError( - "Prowl service reported: exceeded rate limit" - ) from ex _LOGGER.error("Unexpected error when calling Prowl API: %s", str(ex)) raise HomeAssistantError("Unexpected error when calling Prowl API") from ex diff --git a/homeassistant/components/prowl/strings.json b/homeassistant/components/prowl/strings.json index eb136c57999a6a..aba419da7b08ce 100644 --- a/homeassistant/components/prowl/strings.json +++ b/homeassistant/components/prowl/strings.json @@ -17,5 +17,15 @@ "abort": { "already_configured": "API key is already configured" } + }, + "issues": { + "prowl_yaml_deprecated": { + "title": "Prowl YAML configuration deprecated", + "description": "Configuring Prowl using YAML has been deprecated. An automatic import of your existing configuration has been made.\nPlease remove the Prowl configuration from configuration.yaml and update any automations to use the new `notify.send_message` action exposed with this new entity." + }, + "prowl_yaml_migration_fail": { + "title": "Unable to migrate Prowl YAML configuration", + "description": "Configuring Prowl using YAML has been deprecated. An attempt to automatically migrate the existing configuration failed.\nPlease add the entry manually through the UI, remove the old YAML and update any automations to use the new `notify.send_message` action exposed with this new entity." + } } } diff --git a/tests/components/prowl/conftest.py b/tests/components/prowl/conftest.py index 7194bd7397b460..c4f9a63da094c8 100644 --- a/tests/components/prowl/conftest.py +++ b/tests/components/prowl/conftest.py @@ -36,7 +36,7 @@ async def configure_prowl_through_yaml( { NOTIFY_DOMAIN: [ { - "name": DOMAIN, + "name": TEST_NAME, "platform": DOMAIN, "api_key": TEST_API_KEY, }, diff --git a/tests/components/prowl/helpers.py b/tests/components/prowl/helpers.py new file mode 100644 index 00000000000000..1e9ce94263ffd5 --- /dev/null +++ b/tests/components/prowl/helpers.py @@ -0,0 +1,23 @@ +"""Helpers for Prowl tests.""" + +from homeassistant.components.prowl.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + + +def get_config_entry( + hass: HomeAssistant, + api_key: str, + name: str | None = None, + config_method: str | None = None, +) -> ConfigEntry | None: + """Get the Prowl config entry with the specified API key.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data.get(CONF_API_KEY) == api_key: + if name and entry.title != name: + continue + if config_method and entry.source != config_method: + continue + return entry + return None diff --git a/tests/components/prowl/test_config_flow.py b/tests/components/prowl/test_config_flow.py index 4ceb3c89d1781e..2ce607c2b1d9e4 100644 --- a/tests/components/prowl/test_config_flow.py +++ b/tests/components/prowl/test_config_flow.py @@ -56,7 +56,7 @@ async def test_flow_duplicate_api_key( async def test_flow_user_bad_key(hass: HomeAssistant, mock_prowlpy: AsyncMock) -> None: """Test user submitting a bad API key.""" - mock_prowlpy.verify_key.side_effect = prowlpy.APIError("Invalid API key") + mock_prowlpy.verify_key.side_effect = prowlpy.InvalidAPIKeyError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -94,7 +94,7 @@ async def test_flow_user_prowl_timeout( async def test_flow_api_failure(hass: HomeAssistant, mock_prowlpy: AsyncMock) -> None: """Test Prowl API failure.""" - mock_prowlpy.verify_key.side_effect = prowlpy.APIError(BAD_API_RESPONSE) + mock_prowlpy.verify_key.side_effect = prowlpy.BadRequestError result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/prowl/test_init.py b/tests/components/prowl/test_init.py index d222a098633d13..348836bd774b0d 100644 --- a/tests/components/prowl/test_init.py +++ b/tests/components/prowl/test_init.py @@ -9,8 +9,9 @@ from homeassistant.components.prowl.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component -from .conftest import ENTITY_ID, TEST_API_KEY +from .conftest import ENTITY_ID, TEST_API_KEY, TEST_NAME, TEST_SERVICE from tests.common import MockConfigEntry @@ -44,11 +45,11 @@ async def test_load_reload_unload_config_entry( [ (TimeoutError, ConfigEntryState.SETUP_RETRY), ( - prowlpy.APIError(f"Invalid API key: {TEST_API_KEY}"), + prowlpy.InvalidAPIKeyError(f"Invalid API key: {TEST_API_KEY}"), ConfigEntryState.SETUP_ERROR, ), ( - prowlpy.APIError("Not accepted: exceeded rate limit"), + prowlpy.RateLimitExceededError("Not accepted: exceeded rate limit"), ConfigEntryState.SETUP_RETRY, ), (prowlpy.APIError("Internal server error"), ConfigEntryState.SETUP_ERROR), @@ -72,6 +73,44 @@ async def test_config_entry_failures( assert mock_prowlpy.verify_key.call_count > 0 +@pytest.mark.parametrize( + ("prowlpy_side_effect"), + [ + (TimeoutError), + (prowlpy.InvalidAPIKeyError(f"Invalid API key: {TEST_API_KEY}")), + (prowlpy.RateLimitExceededError("Not accepted: exceeded rate limit")), + (prowlpy.APIError("Internal server error")), + ], +) +async def test_yaml_entry_failures( + hass: HomeAssistant, + mock_prowlpy: AsyncMock, + prowlpy_side_effect, +) -> None: + """Test the Prowl configuration entry dealing with bad API key.""" + mock_prowlpy.verify_key.side_effect = prowlpy_side_effect + + await async_setup_component( + hass, + notify.DOMAIN, + { + notify.DOMAIN: [ + { + "name": TEST_NAME, + "platform": DOMAIN, + "api_key": TEST_API_KEY, + }, + ] + }, + ) + await hass.async_block_till_done() + + assert hass.services.has_service(notify.DOMAIN, TEST_SERVICE), ( + "YAML config did not create service" + ) + assert mock_prowlpy.verify_key.call_count > 0 + + @pytest.mark.usefixtures("configure_prowl_through_yaml") async def test_both_yaml_and_config_entry( hass: HomeAssistant, @@ -85,7 +124,7 @@ async def test_both_yaml_and_config_entry( assert mock_prowlpy_config_entry.state is ConfigEntryState.LOADED # Ensure we have the YAML entity service - assert hass.services.has_service(notify.DOMAIN, DOMAIN) + assert hass.services.has_service(notify.DOMAIN, TEST_SERVICE) # Ensure we have the config entry entity service assert hass.states.get(ENTITY_ID) is not None diff --git a/tests/components/prowl/test_notify.py b/tests/components/prowl/test_notify.py index b2cd52d31690e7..cc465bf966c98b 100644 --- a/tests/components/prowl/test_notify.py +++ b/tests/components/prowl/test_notify.py @@ -8,10 +8,14 @@ from homeassistant.components import notify from homeassistant.components.prowl.const import DOMAIN +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component -from .conftest import ENTITY_ID, TEST_API_KEY +from .conftest import ENTITY_ID, OTHER_API_KEY, TEST_API_KEY, TEST_NAME, TEST_SERVICE +from .helpers import get_config_entry from tests.common import MockConfigEntry @@ -32,10 +36,10 @@ async def test_send_notification_service( mock_prowlpy: AsyncMock, ) -> None: """Set up Prowl, call notify service, and check API call.""" - assert hass.services.has_service(notify.DOMAIN, DOMAIN) + assert hass.services.has_service(notify.DOMAIN, TEST_SERVICE) await hass.services.async_call( notify.DOMAIN, - DOMAIN, + TEST_SERVICE, SERVICE_DATA, blocking=True, ) @@ -82,12 +86,12 @@ async def test_send_notification_entity_service( "Timeout accessing Prowl API", ), ( - prowlpy.APIError(f"Invalid API key: {TEST_API_KEY}"), + prowlpy.InvalidAPIKeyError(f"Invalid API key: {TEST_API_KEY}"), HomeAssistantError, "Invalid API key for Prowl service", ), ( - prowlpy.APIError( + prowlpy.RateLimitExceededError( "Not accepted: Your IP address has exceeded the API limit" ), HomeAssistantError, @@ -145,12 +149,12 @@ async def test_fail_send_notification_entity_service( "Timeout accessing Prowl API", ), ( - prowlpy.APIError(f"Invalid API key: {TEST_API_KEY}"), + prowlpy.InvalidAPIKeyError(f"Invalid API key: {TEST_API_KEY}"), HomeAssistantError, "Invalid API key for Prowl service", ), ( - prowlpy.APIError( + prowlpy.RateLimitExceededError( "Not accepted: Your IP address has exceeded the API limit" ), HomeAssistantError, @@ -174,11 +178,11 @@ async def test_fail_send_notification( """Sending a message via Prowl with a failure.""" mock_prowlpy.post.side_effect = prowlpy_side_effect - assert hass.services.has_service(notify.DOMAIN, DOMAIN) + assert hass.services.has_service(notify.DOMAIN, TEST_SERVICE) with pytest.raises(raised_exception, match=exception_message): await hass.services.async_call( notify.DOMAIN, - DOMAIN, + TEST_SERVICE, SERVICE_DATA, blocking=True, ) @@ -211,13 +215,170 @@ async def test_other_exception_send_notification( """Sending a message via Prowl with a general unhandled exception.""" mock_prowlpy.post.side_effect = SyntaxError - assert hass.services.has_service(notify.DOMAIN, DOMAIN) + assert hass.services.has_service(notify.DOMAIN, TEST_SERVICE) with pytest.raises(SyntaxError): await hass.services.async_call( notify.DOMAIN, - DOMAIN, + TEST_SERVICE, SERVICE_DATA, blocking=True, ) mock_prowlpy.post.assert_called_once_with(**expected_send_parameters) + + +@pytest.mark.usefixtures("configure_prowl_through_yaml") +async def test_yaml_migration_creates_config_entry( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that YAML configuration triggers config entry creation.""" + entry = get_config_entry(hass, TEST_API_KEY, config_method="import") + + assert entry is not None, "No import config entry found" + assert entry.data[CONF_API_KEY] == TEST_API_KEY + assert entry.title == TEST_NAME + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_prowl") + + assert issue is not None, "No issue found for YAML deprecation" + assert issue.translation_key == "prowl_yaml_deprecated" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_yaml_migration_with_bad_key( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_prowlpy: AsyncMock +) -> None: + """Test that YAML configuration with bad API key creates issue but no config entry.""" + mock_prowlpy.verify_key.side_effect = prowlpy.InvalidAPIKeyError + + await async_setup_component( + hass, + notify.DOMAIN, + { + notify.DOMAIN: [ + { + "name": TEST_NAME, + "platform": DOMAIN, + "api_key": "invalid_key", + }, + ] + }, + ) + await hass.async_block_till_done() + + entry = get_config_entry(hass, "invalid_key", config_method="import") + assert entry is None, "Config entry should not be created with invalid API key" + + issue = issue_registry.async_get_issue(DOMAIN, "migrate_fail_prowl") + + assert issue is not None, "No issue found for failed YAML migration" + assert issue.translation_key == "prowl_yaml_migration_fail" + assert issue.severity == ir.IssueSeverity.WARNING + + +@pytest.mark.usefixtures("configure_prowl_through_yaml") +async def test_yaml_migration_creates_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that YAML configuration creates a repair issue.""" + issue = issue_registry.async_get_issue(DOMAIN, f"deprecated_yaml_{DOMAIN}") + assert issue is not None + assert issue.translation_key == "prowl_yaml_deprecated" + assert issue.severity == ir.IssueSeverity.WARNING + + +@pytest.mark.usefixtures("mock_prowlpy") +async def test_yaml_migration_migrates_all_entries( + hass: HomeAssistant, +) -> None: + """Test that multiple YAML setups all get migrated.""" + await async_setup_component( + hass, + notify.DOMAIN, + { + notify.DOMAIN: [ + { + "name": DOMAIN, + "platform": DOMAIN, + "api_key": TEST_API_KEY, + }, + { + "name": f"{DOMAIN}_2", + "platform": DOMAIN, + "api_key": OTHER_API_KEY, + }, + ] + }, + ) + await hass.async_block_till_done() + entry = get_config_entry(hass, TEST_API_KEY, config_method="import") + + assert entry is not None, "First import config entry not found" + assert entry.data[CONF_API_KEY] == TEST_API_KEY + + entry = get_config_entry(hass, OTHER_API_KEY, config_method="import") + + assert entry is not None, "Second import config entry not found" + assert entry.data[CONF_API_KEY] == OTHER_API_KEY + + +async def test_yaml_migration_does_not_duplicate_config_entry( + hass: HomeAssistant, + mock_prowlpy_config_entry: MockConfigEntry, +) -> None: + """Test that we don't create duplicates when migrating YAML entities if there are existing ConfigEntries.""" + mock_prowlpy_config_entry.add_to_hass(hass) + + entries_before = [ + e + for e in hass.config_entries.async_entries(DOMAIN) + if e.data.get(CONF_API_KEY) == TEST_API_KEY + ] + + await async_setup_component( + hass, + notify.DOMAIN, + { + notify.DOMAIN: [ + { + "name": TEST_NAME, + "platform": DOMAIN, + "api_key": TEST_API_KEY, + }, + ] + }, + ) + await hass.async_block_till_done() + + entries_after = [ + e + for e in hass.config_entries.async_entries(DOMAIN) + if e.data.get(CONF_API_KEY) == TEST_API_KEY + ] + assert len(entries_after) == len(entries_before), ( + "Duplicate config entry was created" + ) + assert mock_prowlpy_config_entry in entries_after, "Config entry was not created" + + +@pytest.mark.usefixtures("configure_prowl_through_yaml") +async def test_legacy_notify_service_creates_migration_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that calling legacy notify service creates migration issue.""" + await hass.services.async_call( + notify.DOMAIN, + TEST_SERVICE, + SERVICE_DATA, + blocking=True, + ) + + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(notify.DOMAIN, "migrate_notify_prowl_prowl") + + assert issue is not None + assert issue.translation_key == "migrate_notify_service" + assert issue.severity == ir.IssueSeverity.WARNING