From 0996ad4d1df5c48fde908dbf3b2a46a01853bf6b Mon Sep 17 00:00:00 2001 From: Patrick Vorgers Date: Thu, 19 Feb 2026 22:42:04 +0100 Subject: [PATCH 1/2] Add pagination support for IDrive e2 (#162960) --- homeassistant/components/idrive_e2/backup.py | 16 +-- tests/components/idrive_e2/conftest.py | 10 +- tests/components/idrive_e2/test_backup.py | 144 ++++++++++++++++--- 3 files changed, 135 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/idrive_e2/backup.py b/homeassistant/components/idrive_e2/backup.py index 6d58742db8e11..4df337fa27b5f 100644 --- a/homeassistant/components/idrive_e2/backup.py +++ b/homeassistant/components/idrive_e2/backup.py @@ -329,14 +329,14 @@ async def _list_backups(self) -> dict[str, AgentBackup]: return self._backup_cache backups = {} - response = await cast(Any, self._client).list_objects_v2(Bucket=self._bucket) - - # Filter for metadata files only - metadata_files = [ - obj - for obj in response.get("Contents", []) - if obj["Key"].endswith(".metadata.json") - ] + paginator = self._client.get_paginator("list_objects_v2") + metadata_files: list[dict[str, Any]] = [] + async for page in paginator.paginate(Bucket=self._bucket): + metadata_files.extend( + obj + for obj in page.get("Contents", []) + if obj["Key"].endswith(".metadata.json") + ) for metadata_file in metadata_files: try: diff --git a/tests/components/idrive_e2/conftest.py b/tests/components/idrive_e2/conftest.py index b353e06773d5b..0e05cb38d1bfa 100644 --- a/tests/components/idrive_e2/conftest.py +++ b/tests/components/idrive_e2/conftest.py @@ -4,7 +4,7 @@ from collections.abc import AsyncIterator, Generator import json -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -53,9 +53,11 @@ def mock_client(agent_backup: AgentBackup) -> Generator[AsyncMock]: client = create_client.return_value tar_file, metadata_file = suggested_filenames(agent_backup) - client.list_objects_v2.return_value = { - "Contents": [{"Key": tar_file}, {"Key": metadata_file}] - } + # Mock the paginator for list_objects_v2 + client.get_paginator = MagicMock() + client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [ + {"Contents": [{"Key": tar_file}, {"Key": metadata_file}]} + ] client.create_multipart_upload.return_value = {"UploadId": "upload_id"} client.upload_part.return_value = {"ETag": "etag"} client.list_buckets.return_value = { diff --git a/tests/components/idrive_e2/test_backup.py b/tests/components/idrive_e2/test_backup.py index 830e412c53dab..cd3d52ef5deec 100644 --- a/tests/components/idrive_e2/test_backup.py +++ b/tests/components/idrive_e2/test_backup.py @@ -179,7 +179,9 @@ async def test_agents_get_backup_does_not_throw_on_not_found( mock_client: MagicMock, ) -> None: """Test agent get backup does not throw on a backup not found.""" - mock_client.list_objects_v2.return_value = {"Contents": []} + mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [ + {"Contents": []} + ] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"}) @@ -202,18 +204,20 @@ async def test_agents_list_backups_with_corrupted_metadata( agent = IDriveE2BackupAgent(hass, mock_config_entry) # Set up mock responses for both valid and corrupted metadata files - mock_client.list_objects_v2.return_value = { - "Contents": [ - { - "Key": "valid_backup.metadata.json", - "LastModified": "2023-01-01T00:00:00+00:00", - }, - { - "Key": "corrupted_backup.metadata.json", - "LastModified": "2023-01-01T00:00:00+00:00", - }, - ] - } + mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [ + { + "Contents": [ + { + "Key": "valid_backup.metadata.json", + "LastModified": "2023-01-01T00:00:00+00:00", + }, + { + "Key": "corrupted_backup.metadata.json", + "LastModified": "2023-01-01T00:00:00+00:00", + }, + ] + } + ] # Mock responses for get_object calls valid_metadata = json.dumps(agent_backup.as_dict()) @@ -270,7 +274,9 @@ async def test_agents_delete_not_throwing_on_not_found( mock_client: MagicMock, ) -> None: """Test agent delete backup does not throw on a backup not found.""" - mock_client.list_objects_v2.return_value = {"Contents": []} + mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [ + {"Contents": []} + ] client = await hass_ws_client(hass) @@ -284,7 +290,7 @@ async def test_agents_delete_not_throwing_on_not_found( assert response["success"] assert response["result"] == {"agent_errors": {}} - assert mock_client.delete_object.call_count == 0 + assert mock_client.delete_objects.call_count == 0 async def test_agents_upload( @@ -490,20 +496,27 @@ async def test_cache_expiration( metadata_content = json.dumps(agent_backup.as_dict()) mock_body = AsyncMock() mock_body.read.return_value = metadata_content.encode() - mock_client.list_objects_v2.return_value = { - "Contents": [ - {"Key": "test.metadata.json", "LastModified": "2023-01-01T00:00:00+00:00"} - ] - } + mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [ + { + "Contents": [ + { + "Key": "test.metadata.json", + "LastModified": "2023-01-01T00:00:00+00:00", + } + ] + } + ] + + mock_client.get_object.return_value = {"Body": mock_body} # First call should query IDrive e2 await agent.async_list_backups() - assert mock_client.list_objects_v2.call_count == 1 + assert mock_client.get_paginator.call_count == 1 assert mock_client.get_object.call_count == 1 # Second call should use cache await agent.async_list_backups() - assert mock_client.list_objects_v2.call_count == 1 + assert mock_client.get_paginator.call_count == 1 assert mock_client.get_object.call_count == 1 # Set cache to expire @@ -511,7 +524,7 @@ async def test_cache_expiration( # Third call should query IDrive e2 again await agent.async_list_backups() - assert mock_client.list_objects_v2.call_count == 2 + assert mock_client.get_paginator.call_count == 2 assert mock_client.get_object.call_count == 2 @@ -526,3 +539,88 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: remove_listener() assert DATA_BACKUP_AGENT_LISTENERS not in hass.data + + +async def test_list_backups_with_pagination( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test listing backups when paginating through multiple pages.""" + # Create agent + agent = IDriveE2BackupAgent(hass, mock_config_entry) + + # Create two different backups + backup1 = AgentBackup( + backup_id="backup1", + date="2023-01-01T00:00:00+00:00", + addons=[], + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=False, + homeassistant_version=None, + name="Backup 1", + protected=False, + size=0, + ) + backup2 = AgentBackup( + backup_id="backup2", + date="2023-01-02T00:00:00+00:00", + addons=[], + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=False, + homeassistant_version=None, + name="Backup 2", + protected=False, + size=0, + ) + + # Setup two pages of results + page1 = { + "Contents": [ + { + "Key": "backup1.metadata.json", + "LastModified": "2023-01-01T00:00:00+00:00", + }, + {"Key": "backup1.tar", "LastModified": "2023-01-01T00:00:00+00:00"}, + ] + } + page2 = { + "Contents": [ + { + "Key": "backup2.metadata.json", + "LastModified": "2023-01-02T00:00:00+00:00", + }, + {"Key": "backup2.tar", "LastModified": "2023-01-02T00:00:00+00:00"}, + ] + } + + # Setup mock client + mock_client = mock_config_entry.runtime_data + mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [ + page1, + page2, + ] + + # Mock get_object responses based on the key + async def mock_get_object(**kwargs): + """Mock get_object with different responses based on the key.""" + key = kwargs.get("Key", "") + if "backup1" in key: + mock_body = AsyncMock() + mock_body.read.return_value = json.dumps(backup1.as_dict()).encode() + return {"Body": mock_body} + # backup2 + mock_body = AsyncMock() + mock_body.read.return_value = json.dumps(backup2.as_dict()).encode() + return {"Body": mock_body} + + mock_client.get_object.side_effect = mock_get_object + + # List backups and verify we got both + backups = await agent.async_list_backups() + assert len(backups) == 2 + backup_ids = {backup.backup_id for backup in backups} + assert backup_ids == {"backup1", "backup2"} From 6abff84f2322e8264e00f6d55b80c7f56a59e74e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 20 Feb 2026 09:19:03 +1000 Subject: [PATCH 2/2] Add exception translations for Splunk setup errors (#163579) --- homeassistant/components/splunk/__init__.py | 28 +++++++++++++++---- .../components/splunk/quality_scale.yaml | 5 +--- homeassistant/components/splunk/strings.json | 17 +++++++++++ tests/components/splunk/test_init.py | 21 ++++++++++---- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py index 451a39b2d8b08..5f4dfc5cda803 100644 --- a/homeassistant/components/splunk/__init__.py +++ b/homeassistant/components/splunk/__init__.py @@ -179,24 +179,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) except ClientConnectionError as err: raise ConfigEntryNotReady( - f"Connection error connecting to Splunk at {host}:{port}: {err}" + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={ + "host": host, + "port": str(port), + "error": str(err), + }, ) from err except TimeoutError as err: raise ConfigEntryNotReady( - f"Timeout connecting to Splunk at {host}:{port}" + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"host": host, "port": str(port)}, ) from err except Exception as err: _LOGGER.exception("Unexpected error setting up Splunk") raise ConfigEntryNotReady( - f"Unexpected error connecting to Splunk: {err}" + translation_domain=DOMAIN, + translation_key="unexpected_error", + translation_placeholders={ + "host": host, + "port": str(port), + "error": str(err), + }, ) from err if not connectivity_ok: raise ConfigEntryNotReady( - f"Unable to connect to Splunk instance at {host}:{port}" + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": host, "port": str(port)}, ) if not token_ok: - raise ConfigEntryAuthFailed("Invalid Splunk token - please reauthenticate") + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) # Send startup event payload: dict[str, Any] = { diff --git a/homeassistant/components/splunk/quality_scale.yaml b/homeassistant/components/splunk/quality_scale.yaml index 0e874f7f9cb85..eb74b98e13d15 100644 --- a/homeassistant/components/splunk/quality_scale.yaml +++ b/homeassistant/components/splunk/quality_scale.yaml @@ -107,10 +107,7 @@ rules: status: exempt comment: | Integration does not create entities. - exception-translations: - status: todo - comment: | - Consider adding exception translations for user-facing errors beyond the current strings.json error section to provide more detailed translated error messages. + exception-translations: done icon-translations: status: exempt comment: | diff --git a/homeassistant/components/splunk/strings.json b/homeassistant/components/splunk/strings.json index d451ef1ba10ab..822d87e759cc8 100644 --- a/homeassistant/components/splunk/strings.json +++ b/homeassistant/components/splunk/strings.json @@ -48,6 +48,23 @@ } } }, + "exceptions": { + "cannot_connect": { + "message": "Unable to connect to Splunk at {host}:{port}." + }, + "connection_error": { + "message": "Unable to connect to Splunk at {host}:{port}: {error}." + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "timeout_connect": { + "message": "Connection to Splunk at {host}:{port} timed out." + }, + "unexpected_error": { + "message": "Unexpected error while connecting to Splunk at {host}:{port}: {error}." + } + }, "issues": { "deprecated_yaml_import_issue_cannot_connect": { "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release.\n\nWhile importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the connection settings from your `{domain}:` configuration and configure the integration via the UI.\n\nNote: Entity filtering via YAML (`filter:`) will continue to work.", diff --git a/tests/components/splunk/test_init.py b/tests/components/splunk/test_init.py index b4d95081c536b..b52c4e3b2ef94 100644 --- a/tests/components/splunk/test_init.py +++ b/tests/components/splunk/test_init.py @@ -41,12 +41,21 @@ async def test_setup_entry_success( @pytest.mark.parametrize( - ("side_effect", "expected_state"), + ("side_effect", "expected_state", "expected_error_key"), [ - ([False, False], ConfigEntryState.SETUP_RETRY), - (ClientConnectionError("Connection failed"), ConfigEntryState.SETUP_RETRY), - (TimeoutError(), ConfigEntryState.SETUP_RETRY), - ([True, False], ConfigEntryState.SETUP_ERROR), + ([False, False], ConfigEntryState.SETUP_RETRY, "cannot_connect"), + ( + ClientConnectionError("Connection failed"), + ConfigEntryState.SETUP_RETRY, + "connection_error", + ), + (TimeoutError(), ConfigEntryState.SETUP_RETRY, "timeout_connect"), + ( + Exception("Unexpected error"), + ConfigEntryState.SETUP_RETRY, + "unexpected_error", + ), + ([True, False], ConfigEntryState.SETUP_ERROR, "invalid_auth"), ], ) async def test_setup_entry_error( @@ -55,6 +64,7 @@ async def test_setup_entry_error( mock_config_entry: MockConfigEntry, side_effect: Exception | list[bool], expected_state: ConfigEntryState, + expected_error_key: str, ) -> None: """Test setup with various errors results in appropriate states.""" mock_config_entry.add_to_hass(hass) @@ -65,6 +75,7 @@ async def test_setup_entry_error( await hass.async_block_till_done() assert mock_config_entry.state is expected_state + assert mock_config_entry.error_reason_translation_key == expected_error_key async def test_unload_entry(