From c7e30bff179e43c014b19f0785d81fcd664b4e30 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 23 May 2025 11:58:01 +0000 Subject: [PATCH 01/15] Add Backblaze B2 integration --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/backblaze_b2/__init__.py | 135 +++++++++++ .../components/backblaze_b2/backup.py | 228 ++++++++++++++++++ .../components/backblaze_b2/config_flow.py | 120 +++++++++ .../components/backblaze_b2/const.py | 17 ++ .../components/backblaze_b2/manifest.json | 12 + .../backblaze_b2/quality_scale.yaml | 112 +++++++++ .../components/backblaze_b2/strings.json | 53 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/backblaze_b2/__init__.py | 14 ++ tests/components/backblaze_b2/conftest.py | 136 +++++++++++ tests/components/backblaze_b2/const.py | 8 + .../backblaze_b2/test_config_flow.py | 211 ++++++++++++++++ 18 files changed, 1072 insertions(+) create mode 100644 homeassistant/components/backblaze_b2/__init__.py create mode 100644 homeassistant/components/backblaze_b2/backup.py create mode 100644 homeassistant/components/backblaze_b2/config_flow.py create mode 100644 homeassistant/components/backblaze_b2/const.py create mode 100644 homeassistant/components/backblaze_b2/manifest.json create mode 100644 homeassistant/components/backblaze_b2/quality_scale.yaml create mode 100644 homeassistant/components/backblaze_b2/strings.json create mode 100644 tests/components/backblaze_b2/__init__.py create mode 100644 tests/components/backblaze_b2/conftest.py create mode 100644 tests/components/backblaze_b2/const.py create mode 100644 tests/components/backblaze_b2/test_config_flow.py diff --git a/.strict-typing b/.strict-typing index 1ae56cd74d870..e1b3138995e33 100644 --- a/.strict-typing +++ b/.strict-typing @@ -104,6 +104,7 @@ homeassistant.components.automation.* homeassistant.components.awair.* homeassistant.components.axis.* homeassistant.components.azure_storage.* +homeassistant.components.backblaze_b2.* homeassistant.components.backup.* homeassistant.components.baf.* homeassistant.components.bang_olufsen.* diff --git a/CODEOWNERS b/CODEOWNERS index be7c1e5ee84d2..8c673e2e41d70 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -184,6 +184,8 @@ build.json @home-assistant/supervisor /homeassistant/components/azure_service_bus/ @hfurubotten /homeassistant/components/azure_storage/ @zweckj /tests/components/azure_storage/ @zweckj +/homeassistant/components/backblaze_b2/ @hugo-vrijswijk +/tests/components/backblaze_b2/ @hugo-vrijswijk /homeassistant/components/backup/ @home-assistant/core /tests/components/backup/ @home-assistant/core /homeassistant/components/baf/ @bdraco @jfroy diff --git a/homeassistant/components/backblaze_b2/__init__.py b/homeassistant/components/backblaze_b2/__init__.py new file mode 100644 index 0000000000000..8ffa9e4298cc2 --- /dev/null +++ b/homeassistant/components/backblaze_b2/__init__.py @@ -0,0 +1,135 @@ +"""The Backblaze B2 integration.""" + +from __future__ import annotations + +import logging +from typing import cast + +from b2sdk.v2 import B2Api, Bucket, InMemoryAccountInfo, exception + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import ( + CONF_APPLICATION_KEY, + CONF_BUCKET, + CONF_KEY_ID, + CONF_PREFIX, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) + +type BackblazeConfigEntry = ConfigEntry[BackblazeConfig] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> bool: + """Set up Backblaze B2 from a config entry.""" + + info = InMemoryAccountInfo() + b2_api = B2Api(info) + + data = cast(dict, entry.data) + prefix = data[CONF_PREFIX] + + try: + _LOGGER.info( + "Connecting to Backblaze B2 with application key id %s", + data[CONF_KEY_ID], + ) + await hass.async_add_executor_job( + b2_api.authorize_account, + "production", + data[CONF_KEY_ID], + data[CONF_APPLICATION_KEY], + ) + + bucket = await hass.async_add_executor_job( + b2_api.get_bucket_by_name, data[CONF_BUCKET] + ) + allowed = b2_api.account_info.get_allowed() + + # Check if capabilities contains 'writeFiles' and 'listFiles' and 'deleteFiles' and 'readFiles' + if allowed is not None: + if allowed is not None: + capabilities = allowed["capabilities"] + if not capabilities or not all( + capability in capabilities + for capability in ( + "writeFiles", + "listFiles", + "deleteFiles", + "readFiles", + ) + ): + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_capability", + ) + + allowed_prefix = cast(str, allowed.get("namePrefix", "")) + if allowed_prefix and not prefix.startswith(allowed_prefix): + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_prefix", + translation_placeholders={ + "allowed_prefix": allowed_prefix, + }, + ) + + except exception.Unauthorized as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_credentials", + ) from err + except exception.RestrictedBucket as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="restricted_bucket", + translation_placeholders={ + "restricted_bucket_name": err.bucket_name, + }, + ) from err + except exception.NonExistentBucket as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_bucket_name", + ) from err + except exception.ConnectionReset as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except exception.MissingAccountData as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + + if prefix and not prefix.endswith("/"): + prefix += "/" + + entry.runtime_data = BackblazeConfig(bucket, prefix) + + def notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(notify_backup_listeners)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> bool: + """Unload a config entry.""" + return True + + +class BackblazeConfig: + """Small wrapper for Backblaze configuration.""" + + def __init__(self, bucket: Bucket, prefix: str) -> None: # noqa: D107 + self.bucket = bucket + self.prefix = prefix diff --git a/homeassistant/components/backblaze_b2/backup.py b/homeassistant/components/backblaze_b2/backup.py new file mode 100644 index 0000000000000..714d2bac926cf --- /dev/null +++ b/homeassistant/components/backblaze_b2/backup.py @@ -0,0 +1,228 @@ +"""Backup platform for the Backblaze B2 integration.""" + +from collections.abc import AsyncIterator, Callable, Coroutine +import functools +import json +import logging +import os +from typing import Any + +from b2sdk.v2 import FileVersion +from b2sdk.v2.exception import B2Error + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback + +from . import BackblazeConfigEntry +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) +METADATA_VERSION = "1" + + +def handle_b2_errors[T]( + func: Callable[..., Coroutine[Any, Any, T]], +) -> Callable[..., Coroutine[Any, Any, T]]: + """Handle B2Errors by converting them to BackupAgentError.""" + + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> T: + """Catch B2Error and raise BackupAgentError.""" + try: + return await func(*args, **kwargs) + except B2Error as err: + error_msg = f"Failed during {func.__name__}" + raise BackupAgentError(error_msg) from err + + return wrapper + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[BackblazeConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + return [BackblazeBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +class BackblazeBackupAgent(BackupAgent): + """Backup agent for Backblaze B2.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: BackblazeConfigEntry) -> None: + """Initialize the Backblaze agent.""" + super().__init__() + self._hass = hass + self.async_create_task = entry.async_create_task + self._bucket = entry.runtime_data.bucket + self._prefix = entry.runtime_data.prefix + + self.name = entry.title + self.unique_id = entry.entry_id + + @handle_b2_errors + async def async_download_backup( + self, backup_id: str, **kwargs: Any + ) -> AsyncIterator[bytes]: + """Download a backup.""" + + file = await self._find_file_by_id(backup_id) + if file is None: + raise BackupNotFound(f"Backup {backup_id} not found") + + _LOGGER.debug("Downloading %s", file.file_name) + + downloaded_file = await self._hass.async_add_executor_job(file.download) + response = downloaded_file.response + + iterator = response.iter_content(chunk_size=8192) + + async def stream_buffer() -> AsyncIterator[bytes]: + """Stream the response into an AsyncIterator.""" + while True: + chunk = await self._hass.async_add_executor_job(next, iterator, None) + if chunk is None: + break + yield chunk + + return stream_buffer() + + @handle_b2_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + + file_info = { + "metadata_version": METADATA_VERSION, + "backup_id": backup.backup_id, + "backup_metadata": json.dumps(backup.as_dict()), + } + + filename = self._prefix + suggested_filename(backup) + + _LOGGER.debug("Uploading %s", filename) + + stream: AsyncIterator[bytes] = await open_stream() + + # Create a pipe (file descriptors) to bridge async writes and sync reads + r_fd, w_fd = os.pipe() + + async def writer() -> None: + """Write async stream to the pipe.""" + with os.fdopen(w_fd, "wb") as w: + async for chunk in stream: + w.write(chunk) + w.close() + + # Schedule the writer coroutine + writer_task = self.async_create_task(self._hass, writer()) + + def upload() -> None: + with os.fdopen(r_fd, "rb") as r: + self._bucket.upload_unbound_stream( + r, + filename, + file_info=file_info, + ) + + await self._hass.async_add_executor_job(upload) + await writer_task + + @handle_b2_errors + async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: + """Delete a backup.""" + file = await self._find_file_by_id(backup_id) + if file is None: + raise BackupNotFound(f"Backup {backup_id} not found") + _LOGGER.debug("Deleting %s", backup_id) + await self._hass.async_add_executor_job(file.delete) + + @handle_b2_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List all backups.""" + backups: list[AgentBackup] = [] + + def get_files() -> None: + for [file, _] in self._bucket.ls(self._prefix): + if ( + file.file_info is not None + and file.file_info.get("metadata_version") == METADATA_VERSION + ): + backups.append( + AgentBackup.from_dict( + json.loads(file.file_info["backup_metadata"]) + ) + ) + + await self._hass.async_add_executor_job(get_files) + _LOGGER.debug("Found %d backups", len(backups)) + return backups + + @handle_b2_errors + async def async_get_backup(self, backup_id: str, **kwargs: Any) -> AgentBackup: + """Get a backup.""" + return await self._find_backup_by_id(backup_id) + + async def _find_file_by_id(self, backup_id: str) -> FileVersion | None: + """Find a file by its ID.""" + + def find_file() -> FileVersion | None: + for [file, _] in self._bucket.ls(self._prefix): + if ( + file.file_info is not None + and file.file_info.get("metadata_version") == METADATA_VERSION + and file.file_info.get("backup_id") == backup_id + ): + _LOGGER.debug("Found file %s from id %s", file.file_name, backup_id) + return file + _LOGGER.debug("File %s not found", backup_id) + return None + + return await self._hass.async_add_executor_job(find_file) + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: + """Find a backup by its ID.""" + file = await self._find_file_by_id(backup_id) + if file is None: + raise BackupNotFound(f"Backup {backup_id} not found") + metadata = file.file_info.get("backup_metadata") + if metadata is None: + raise BackupNotFound(f"Backup {backup_id} not found") + return AgentBackup.from_dict(json.loads(metadata)) diff --git a/homeassistant/components/backblaze_b2/config_flow.py b/homeassistant/components/backblaze_b2/config_flow.py new file mode 100644 index 0000000000000..66a7748196668 --- /dev/null +++ b/homeassistant/components/backblaze_b2/config_flow.py @@ -0,0 +1,120 @@ +"""Config flow for the Backblaze B2 integration.""" + +from __future__ import annotations + +import logging +from typing import Any, cast + +from b2sdk.v2 import B2Api, InMemoryAccountInfo, exception +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_APPLICATION_KEY, CONF_BUCKET, CONF_KEY_ID, CONF_PREFIX, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_KEY_ID): cv.string, + vol.Required(CONF_APPLICATION_KEY): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required(CONF_BUCKET): cv.string, + vol.Optional(CONF_PREFIX, default=""): cv.string, + } +) + + +class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Backblaze B2.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match( + { + CONF_KEY_ID: user_input[CONF_KEY_ID], + CONF_APPLICATION_KEY: user_input[CONF_APPLICATION_KEY], + } + ) + + info = InMemoryAccountInfo() + b2_api = B2Api(info) + + try: + _LOGGER.info( + "Connecting to Backblaze B2 with application key id %s", + user_input[CONF_KEY_ID], + ) + await self.hass.async_add_executor_job( + b2_api.authorize_account, + "production", + user_input[CONF_KEY_ID], + user_input[CONF_APPLICATION_KEY], + ) + + await self.hass.async_add_executor_job( + b2_api.get_bucket_by_name, user_input[CONF_BUCKET] + ) + + allowed = b2_api.account_info.get_allowed() + + # Check if capabilities contains 'writeFiles' and 'listFiles' and 'deleteFiles' and 'readFiles' + if allowed is not None: + capabilities = allowed["capabilities"] + if not capabilities or not all( + capability in capabilities + for capability in ( + "writeFiles", + "listFiles", + "deleteFiles", + "readFiles", + ) + ): + errors["base"] = "invalid_capability" + + prefix: str = user_input[CONF_PREFIX] + allowed_prefix = cast(str, allowed.get("namePrefix", "")) + if allowed_prefix and not prefix.startswith(allowed_prefix): + errors[CONF_PREFIX] = "invalid_prefix" + placeholders["allowed_prefix"] = allowed_prefix + + except exception.Unauthorized: + errors["base"] = "invalid_credentials" + except exception.RestrictedBucket as err: + placeholders["restricted_bucket_name"] = err.bucket_name + errors[CONF_BUCKET] = "restricted_bucket" + except exception.NonExistentBucket: + errors[CONF_BUCKET] = "invalid_bucket_name" + except exception.ConnectionReset: + errors["base"] = "cannot_connect" + except exception.MissingAccountData: + errors["base"] = "invalid_credentials" + else: + if not errors: + return self.async_create_entry( + title=user_input[CONF_BUCKET], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + description_placeholders=placeholders, + ) diff --git a/homeassistant/components/backblaze_b2/const.py b/homeassistant/components/backblaze_b2/const.py new file mode 100644 index 0000000000000..9567be148fbc6 --- /dev/null +++ b/homeassistant/components/backblaze_b2/const.py @@ -0,0 +1,17 @@ +"""Constants for the Backblaze B2 integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "backblaze_b2" + +CONF_KEY_ID = "key_id" +CONF_APPLICATION_KEY = "application_key" +CONF_BUCKET = "bucket" +CONF_PREFIX = "prefix" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/backblaze_b2/manifest.json b/homeassistant/components/backblaze_b2/manifest.json new file mode 100644 index 0000000000000..d3fc5f696858e --- /dev/null +++ b/homeassistant/components/backblaze_b2/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "backblaze_b2", + "name": "Backblaze B2", + "codeowners": ["@hugo-vrijswijk"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/backblaze_b2", + "integration_type": "service", + "iot_class": "cloud_push", + "loggers": ["b2sdk"], + "quality_scale": "bronze", + "requirements": ["b2sdk==2.8.1"] +} diff --git a/homeassistant/components/backblaze_b2/quality_scale.yaml b/homeassistant/components/backblaze_b2/quality_scale.yaml new file mode 100644 index 0000000000000..5564967b8075c --- /dev/null +++ b/homeassistant/components/backblaze_b2/quality_scale.yaml @@ -0,0 +1,112 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: Integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities of this integration do not explicitly subscribe to events. + entity-unique-id: + status: exempt + comment: | + This integration does not have entities. + has-entity-name: + status: exempt + comment: | + This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: This integration does not have entities. + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: exempt + comment: This integration does not poll. + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: + status: exempt + comment: This integration does not have entities. + diagnostics: + status: exempt + comment: There is no data to diagnose. + discovery-update-info: + status: exempt + comment: Backblaze is a cloud service that is not discovered on the network. + discovery: + status: exempt + comment: Backblaze is a cloud service that is not discovered on the network. + docs-data-update: + status: exempt + comment: This integration does not poll. + docs-examples: + status: exempt + comment: The integration extends core functionality and does not require examples. + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: This integration does not support physical devices. + docs-supported-functions: + status: exempt + comment: This integration does not have entities. + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: This integration does not have devices. + entity-category: + status: exempt + comment: This integration does not have entities. + entity-device-class: + status: exempt + comment: This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: This integration does not have entities. + entity-translations: + status: exempt + comment: This integration does not have entities. + exception-translations: todo + icon-translations: + status: exempt + comment: This integration does not use icons. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: This integration does not have devices. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/backblaze_b2/strings.json b/homeassistant/components/backblaze_b2/strings.json new file mode 100644 index 0000000000000..27b7e1722fd96 --- /dev/null +++ b/homeassistant/components/backblaze_b2/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "user": { + "data": { + "key_id": "Key ID", + "application_key": "Application key", + "bucket": "Bucket name", + "prefix": "Folder prefix (optional)" + }, + "data_description": { + "key_id": "Key ID to connect to Backblaze", + "application_key": "Application key to connect to Backblaze", + "bucket": "Bucket must already exist and be writable by the provided credentials.", + "prefix": "Prefix folder to store back files in (no trailing slash). Leave empty to store in the root." + }, + "title": "Add Backblaze B2 backup" + } + }, + "error": { + "restricted_bucket": "[%key:component::backblaze_b2::exceptions::restricted_bucket::message%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_bucket_name": "[%key:component::backblaze_b2::exceptions::invalid_bucket_name::message%]", + "invalid_credentials": "[%key:component::backblaze_b2::exceptions::invalid_credentials::message%]", + "invalid_capability": "[%key:component::backblaze_b2::exceptions::invalid_capability::message%]", + "invalid_prefix": "[%key:component::backblaze_b2::exceptions::invalid_prefix::message%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "exceptions": { + "restricted_bucket": { + "message": "Application key is restricted to bucket {restricted_bucket_name}." + }, + "cannot_connect": { + "message": "Cannot connect to endpoint" + }, + "invalid_bucket_name": { + "message": "Bucket does not exist or is not writable by the provided credentials." + }, + "invalid_credentials": { + "message": "Bucket cannot be accessed using provided of key ID and application key." + }, + "invalid_capability": { + "message": "Application key does not have the required read/write capabilities." + }, + "invalid_prefix": { + "message": "Prefix is not allowed for provided key. Must start with {allowed_prefix}." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e1211ac20d0ab..222a1a91a6f91 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -81,6 +81,7 @@ "azure_devops", "azure_event_hub", "azure_storage", + "backblaze_b2", "baf", "balboa", "bang_olufsen", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7f335f4091d89..36016a1ee209c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -617,6 +617,12 @@ "config_flow": true, "iot_class": "local_push" }, + "backblaze_b2": { + "name": "Backblaze B2", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_push" + }, "backup": { "name": "Backup", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index cf3314f515ce9..3c93d33fe0e1b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -795,6 +795,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.backblaze_b2.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.backup.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index ed0490444400f..52899a32bed20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -578,6 +578,9 @@ azure-servicebus==7.10.0 # homeassistant.components.azure_storage azure-storage-blob==12.24.0 +# homeassistant.components.backblaze_b2 +b2sdk==2.8.1 + # homeassistant.components.holiday babel==2.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0244b601e9b09..8502bb320ce93 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -524,6 +524,9 @@ azure-kusto-ingest==4.5.1 # homeassistant.components.azure_storage azure-storage-blob==12.24.0 +# homeassistant.components.backblaze_b2 +b2sdk==2.8.1 + # homeassistant.components.holiday babel==2.15.0 diff --git a/tests/components/backblaze_b2/__init__.py b/tests/components/backblaze_b2/__init__.py new file mode 100644 index 0000000000000..0d2ae06216182 --- /dev/null +++ b/tests/components/backblaze_b2/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Backblaze B2 integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Backblaze integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/backblaze_b2/conftest.py b/tests/components/backblaze_b2/conftest.py new file mode 100644 index 0000000000000..eb9e7ff14f560 --- /dev/null +++ b/tests/components/backblaze_b2/conftest.py @@ -0,0 +1,136 @@ +"""Common fixtures for the Backblaze B2 tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from b2sdk.v2 import RawSimulator +import pytest + +from homeassistant.components.backblaze_b2.const import CONF_BUCKET, DOMAIN +from homeassistant.components.backup import AgentBackup + +from .const import USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.backblaze_b2.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def b2_fixture(): + """Create account and application keys.""" + with patch("b2sdk.v2.B2Api", return_value=RawSimulator()) as mock_client: + RawSimulator.get_bucket_by_name = RawSimulator._get_bucket_by_name + + allowed = { + "capabilities": [ + "writeFiles", + "listFiles", + "deleteFiles", + "readFiles", + ] + } + RawSimulator.account_info = AccountInfo(allowed) + + sim: RawSimulator = mock_client.return_value + account_id, application_key = sim.create_account() + auth = sim.authorize_account("production", account_id, application_key) + auth_token: str = auth["authorizationToken"] + api_url: str = auth["apiInfo"]["storageApi"]["apiUrl"] + + key = sim.create_key( + api_url=api_url, + account_auth_token=auth_token, + account_id=account_id, + key_name="testkey", + capabilities=[ + "writeFiles", + "listFiles", + "deleteFiles", + "readFiles", + ], + valid_duration_seconds=None, + bucket_id=None, + name_prefix=None, + ) + + application_key_id: str = key["applicationKeyId"] + application_key: str = key["applicationKey"] + + bucket = sim.create_bucket( + api_url=api_url, + account_id=account_id, + account_auth_token=auth_token, + bucket_name=USER_INPUT[CONF_BUCKET], + bucket_type="allPrivate", + ) + + yield BackblazeFixture(application_key_id, application_key, bucket, sim, auth) + + +@pytest.fixture +def backup_for_test() -> AgentBackup: + """Test backup fixture.""" + return AgentBackup( + addons=[], + backup_id="23e64aec", + date="2024-11-22T11:48:48.727189+01:00", + database_included=True, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="Core 2024.12.0.dev0", + protected=False, + size=2**20, + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + entry_id="test", + title="test", + domain=DOMAIN, + data=USER_INPUT, + ) + + +class BackblazeFixture: + """Mock Backblaze B2 account.""" + + def __init__( # noqa: D107 + self, + key_id: str, + application_key: str, + bucket: dict[str, Any], + sim: RawSimulator, + auth: dict[str, Any], + ) -> None: + self.key_id = key_id + self.application_key = application_key + self.bucket = bucket + self.sim = sim + self.auth = auth + self.api_url = auth["apiInfo"]["storageApi"]["apiUrl"] + self.account_id = auth["accountId"] + + +class AccountInfo: + """Mock account info.""" + + def __init__(self, allowed: dict[str, Any]) -> None: # noqa: D107 + self._allowed = allowed + + def get_allowed(self): + """Return allowed capabilities.""" + return self._allowed diff --git a/tests/components/backblaze_b2/const.py b/tests/components/backblaze_b2/const.py new file mode 100644 index 0000000000000..0ef1751b4888c --- /dev/null +++ b/tests/components/backblaze_b2/const.py @@ -0,0 +1,8 @@ +"""Consts for Backblaze B2 tests.""" + +from homeassistant.components.backblaze_b2.const import CONF_BUCKET, CONF_PREFIX + +USER_INPUT = { + CONF_BUCKET: "testBucket", + CONF_PREFIX: "testprefix", +} diff --git a/tests/components/backblaze_b2/test_config_flow.py b/tests/components/backblaze_b2/test_config_flow.py new file mode 100644 index 0000000000000..8aae6c4160c0c --- /dev/null +++ b/tests/components/backblaze_b2/test_config_flow.py @@ -0,0 +1,211 @@ +"""Test the Backblaze B2 config flow.""" + +from unittest.mock import patch + +from b2sdk.v2 import exception + +from homeassistant import config_entries +from homeassistant.components.backblaze_b2.const import ( + CONF_APPLICATION_KEY, + CONF_KEY_ID, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import BackblazeFixture +from .const import USER_INPUT + +from tests.common import MockConfigEntry + + +async def _async_start_flow( + hass: HomeAssistant, + key_id: str, + application_key: str, + user_input: dict[str, str] | None = None, +) -> config_entries.ConfigFlowResult: + """Initialize the config flow.""" + if user_input is None: + user_input = USER_INPUT + + user_input[CONF_KEY_ID] = key_id + user_input[CONF_APPLICATION_KEY] = application_key + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + + +async def test_flow(hass: HomeAssistant, b2_fixture: BackblazeFixture) -> None: + """Test config flow.""" + result = await _async_start_flow( + hass, b2_fixture.key_id, b2_fixture.application_key + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "testBucket" + assert result.get("data") == USER_INPUT + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + b2_fixture: BackblazeFixture, +) -> None: + """Test we abort if the account is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await _async_start_flow( + hass, b2_fixture.key_id, b2_fixture.application_key + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test config flow.""" + result = await _async_start_flow(hass, "invalid", "invalid") + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": "invalid_credentials"} + + +async def test_form_invalid_bucket_name( + hass: HomeAssistant, + b2_fixture: BackblazeFixture, +) -> None: + """Test config flow.""" + result = await _async_start_flow( + hass, + b2_fixture.key_id, + b2_fixture.application_key, + { + **USER_INPUT, + "bucket": "invalid-bucket-name", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"bucket": "invalid_bucket_name"} + + +async def test_form_cannot_connect( + hass: HomeAssistant, + b2_fixture: BackblazeFixture, +) -> None: + """Test config flow.""" + with patch( + "b2sdk.v2.RawSimulator.authorize_account", + side_effect=exception.ConnectionReset("test"), + ): + result = await _async_start_flow( + hass, + b2_fixture.key_id, + b2_fixture.application_key, + USER_INPUT, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": "cannot_connect"} + + +async def test_form_restricted_bucket( + hass: HomeAssistant, + b2_fixture: BackblazeFixture, +) -> None: + """Test config flow.""" + with patch( + "b2sdk.v2.RawSimulator.get_bucket_by_name", + side_effect=exception.RestrictedBucket("testBucket"), + ): + result = await _async_start_flow( + hass, + b2_fixture.key_id, + b2_fixture.application_key, + USER_INPUT, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"bucket": "restricted_bucket"} + assert result.get("description_placeholders") == { + "restricted_bucket_name": "testBucket", + } + + +async def test_form_missing_account_data( + hass: HomeAssistant, + b2_fixture: BackblazeFixture, +) -> None: + """Test config flow.""" + with patch( + "b2sdk.v2.RawSimulator.authorize_account", + side_effect=exception.MissingAccountData("key"), + ): + result = await _async_start_flow( + hass, + b2_fixture.key_id, + b2_fixture.application_key, + USER_INPUT, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": "invalid_credentials"} + + +async def test_form_invalid_capability( + hass: HomeAssistant, + b2_fixture: BackblazeFixture, +) -> None: + """Test config flow.""" + with patch( + "b2sdk.v2.RawSimulator.account_info.get_allowed", + return_value={ + "capabilities": [ + "writeFiles", + "listFiles", + "deleteFiles", + ] + }, + ): + result = await _async_start_flow( + hass, + b2_fixture.key_id, + b2_fixture.application_key, + USER_INPUT, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": "invalid_capability"} + + +async def test_form_invalid_prefix( + hass: HomeAssistant, + b2_fixture: BackblazeFixture, +) -> None: + """Test config flow.""" + with patch( + "b2sdk.v2.RawSimulator.account_info.get_allowed", + return_value={ + "capabilities": [ + "writeFiles", + "listFiles", + "deleteFiles", + "readFiles", + ], + "namePrefix": "test/", + }, + ): + result = await _async_start_flow( + hass, + b2_fixture.key_id, + b2_fixture.application_key, + USER_INPUT, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"prefix": "invalid_prefix"} + assert result.get("description_placeholders") == { + "allowed_prefix": "test/", + } From 045369d655e2161c9c1ab5aecac58f6d0c635eba Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 23 May 2025 16:32:18 +0000 Subject: [PATCH 02/15] Add tests for backup and init --- .../components/backblaze_b2/__init__.py | 33 +------- tests/components/backblaze_b2/__init__.py | 2 +- tests/components/backblaze_b2/conftest.py | 40 ++++------ tests/components/backblaze_b2/const.py | 24 ++++++ tests/components/backblaze_b2/test_backup.py | 77 +++++++++++++++++++ tests/components/backblaze_b2/test_init.py | 76 ++++++++++++++++++ 6 files changed, 197 insertions(+), 55 deletions(-) create mode 100644 tests/components/backblaze_b2/test_backup.py create mode 100644 tests/components/backblaze_b2/test_init.py diff --git a/homeassistant/components/backblaze_b2/__init__.py b/homeassistant/components/backblaze_b2/__init__.py index 8ffa9e4298cc2..319cc09d422f6 100644 --- a/homeassistant/components/backblaze_b2/__init__.py +++ b/homeassistant/components/backblaze_b2/__init__.py @@ -49,35 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> bucket = await hass.async_add_executor_job( b2_api.get_bucket_by_name, data[CONF_BUCKET] ) - allowed = b2_api.account_info.get_allowed() - - # Check if capabilities contains 'writeFiles' and 'listFiles' and 'deleteFiles' and 'readFiles' - if allowed is not None: - if allowed is not None: - capabilities = allowed["capabilities"] - if not capabilities or not all( - capability in capabilities - for capability in ( - "writeFiles", - "listFiles", - "deleteFiles", - "readFiles", - ) - ): - raise ConfigEntryError( - translation_domain=DOMAIN, - translation_key="invalid_capability", - ) - - allowed_prefix = cast(str, allowed.get("namePrefix", "")) - if allowed_prefix and not prefix.startswith(allowed_prefix): - raise ConfigEntryError( - translation_domain=DOMAIN, - translation_key="invalid_prefix", - translation_placeholders={ - "allowed_prefix": allowed_prefix, - }, - ) except exception.Unauthorized as err: raise ConfigEntryError( @@ -113,11 +84,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> entry.runtime_data = BackblazeConfig(bucket, prefix) - def notify_backup_listeners() -> None: + def _async_notify_backup_listeners() -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): listener() - entry.async_on_unload(entry.async_on_state_change(notify_backup_listeners)) + entry.async_on_unload(entry.async_on_state_change(_async_notify_backup_listeners)) return True diff --git a/tests/components/backblaze_b2/__init__.py b/tests/components/backblaze_b2/__init__.py index 0d2ae06216182..88d1c63a50a61 100644 --- a/tests/components/backblaze_b2/__init__.py +++ b/tests/components/backblaze_b2/__init__.py @@ -8,7 +8,7 @@ async def setup_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Set up the Backblaze integration for testing.""" + """Set up the backblaze_b2 integration for testing.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/backblaze_b2/conftest.py b/tests/components/backblaze_b2/conftest.py index eb9e7ff14f560..53e249de036c6 100644 --- a/tests/components/backblaze_b2/conftest.py +++ b/tests/components/backblaze_b2/conftest.py @@ -7,8 +7,12 @@ from b2sdk.v2 import RawSimulator import pytest -from homeassistant.components.backblaze_b2.const import CONF_BUCKET, DOMAIN -from homeassistant.components.backup import AgentBackup +from homeassistant.components.backblaze_b2.const import ( + CONF_APPLICATION_KEY, + CONF_BUCKET, + CONF_KEY_ID, + DOMAIN, +) from .const import USER_INPUT @@ -27,7 +31,11 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(autouse=True) def b2_fixture(): """Create account and application keys.""" - with patch("b2sdk.v2.B2Api", return_value=RawSimulator()) as mock_client: + sim = RawSimulator() + with ( + patch("b2sdk.v2.B2Api", return_value=sim) as mock_client, + patch("homeassistant.components.backblaze_b2.B2Api", return_value=sim), + ): RawSimulator.get_bucket_by_name = RawSimulator._get_bucket_by_name allowed = { @@ -77,31 +85,17 @@ def b2_fixture(): @pytest.fixture -def backup_for_test() -> AgentBackup: - """Test backup fixture.""" - return AgentBackup( - addons=[], - backup_id="23e64aec", - date="2024-11-22T11:48:48.727189+01:00", - database_included=True, - extra_metadata={}, - folders=[], - homeassistant_included=True, - homeassistant_version="2024.12.0.dev0", - name="Core 2024.12.0.dev0", - protected=False, - size=2**20, - ) - - -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry(b2_fixture: Any) -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( entry_id="test", title="test", domain=DOMAIN, - data=USER_INPUT, + data={ + **USER_INPUT, + CONF_KEY_ID: b2_fixture.key_id, + CONF_APPLICATION_KEY: b2_fixture.application_key, + }, ) diff --git a/tests/components/backblaze_b2/const.py b/tests/components/backblaze_b2/const.py index 0ef1751b4888c..f290748177d94 100644 --- a/tests/components/backblaze_b2/const.py +++ b/tests/components/backblaze_b2/const.py @@ -1,8 +1,32 @@ """Consts for Backblaze B2 tests.""" +from json import dumps + from homeassistant.components.backblaze_b2.const import CONF_BUCKET, CONF_PREFIX +from homeassistant.components.backup import AgentBackup USER_INPUT = { CONF_BUCKET: "testBucket", CONF_PREFIX: "testprefix", } + + +TEST_BACKUP = AgentBackup( + addons=[], + backup_id="23e64aec", + date="2024-11-22T11:48:48.727189+01:00", + database_included=True, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="Core 2024.12.0.dev0", + protected=False, + size=34519040, +) + +BACKUP_METADATA = { + "metadata_version": "1", + "backup_id": "23e64aec", + "backup_metadata": dumps(TEST_BACKUP.as_dict()), +} diff --git a/tests/components/backblaze_b2/test_backup.py b/tests/components/backblaze_b2/test_backup.py new file mode 100644 index 0000000000000..cfbd3e84e1559 --- /dev/null +++ b/tests/components/backblaze_b2/test_backup.py @@ -0,0 +1,77 @@ +"""Test Backblaze B2 backup agent.""" + +from collections.abc import AsyncGenerator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.backblaze_b2.backup import ( + async_register_backup_agents_listener, +) +from homeassistant.components.backblaze_b2.const import ( + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up backblaze_b2 integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + async_initialize_backup(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = MagicMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [ + listener + ] # make sure it's the last listener + remove_listener() + + assert DATA_BACKUP_AGENT_LISTENERS not in hass.data diff --git a/tests/components/backblaze_b2/test_init.py b/tests/components/backblaze_b2/test_init.py new file mode 100644 index 0000000000000..b37df88b8daeb --- /dev/null +++ b/tests/components/backblaze_b2/test_init.py @@ -0,0 +1,76 @@ +"""Test the Backblaze B2 storage integration.""" + +from unittest.mock import patch + +from b2sdk.v2 import exception +import pytest + +from homeassistant.components.backblaze_b2.const import CONF_APPLICATION_KEY +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup entry with invalid auth.""" + mock_config = MockConfigEntry( + entry_id=mock_config_entry.entry_id, + title=mock_config_entry.title, + domain=mock_config_entry.domain, + data={ + **mock_config_entry.data, + CONF_APPLICATION_KEY: "invalid_key_id", + }, + ) + + await setup_integration(hass, mock_config) + + assert mock_config.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (exception.Unauthorized("msg", "code"), ConfigEntryState.SETUP_ERROR), + (exception.RestrictedBucket("testBucket"), ConfigEntryState.SETUP_ERROR), + (exception.NonExistentBucket(), ConfigEntryState.SETUP_ERROR), + (exception.ConnectionReset(), ConfigEntryState.SETUP_RETRY), + (exception.MissingAccountData("key"), ConfigEntryState.SETUP_ERROR), + ], +) +async def test_setup_entry_restricted_bucket( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test setup entry with restricted bucket.""" + + with patch( + "b2sdk.v2.RawSimulator.get_bucket_by_name", + side_effect=exception, + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is state From 7a8dfb25897f0c8438c4965a0e1cfb9e1dd2422c Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 23 May 2025 23:10:26 +0000 Subject: [PATCH 03/15] Improve test coverage --- .../backblaze_b2/quality_scale.yaml | 2 +- tests/components/backblaze_b2/conftest.py | 54 ++++- tests/components/backblaze_b2/test_backup.py | 188 +++++++++++++++++- 3 files changed, 239 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/backblaze_b2/quality_scale.yaml b/homeassistant/components/backblaze_b2/quality_scale.yaml index 5564967b8075c..1153bd0d52fdf 100644 --- a/homeassistant/components/backblaze_b2/quality_scale.yaml +++ b/homeassistant/components/backblaze_b2/quality_scale.yaml @@ -51,7 +51,7 @@ rules: status: exempt comment: This integration does not poll. reauthentication-flow: todo - test-coverage: todo + test-coverage: done # Gold devices: diff --git a/tests/components/backblaze_b2/conftest.py b/tests/components/backblaze_b2/conftest.py index 53e249de036c6..49d9efde01156 100644 --- a/tests/components/backblaze_b2/conftest.py +++ b/tests/components/backblaze_b2/conftest.py @@ -1,10 +1,13 @@ """Common fixtures for the Backblaze B2 tests.""" from collections.abc import Generator +import hashlib +import io from typing import Any from unittest.mock import AsyncMock, patch -from b2sdk.v2 import RawSimulator +from b2sdk._internal.raw_simulator import BucketSimulator +from b2sdk.v2 import FileVersion, RawSimulator import pytest from homeassistant.components.backblaze_b2.const import ( @@ -14,7 +17,7 @@ DOMAIN, ) -from .const import USER_INPUT +from .const import BACKUP_METADATA, TEST_BACKUP, USER_INPUT from tests.common import MockConfigEntry @@ -81,6 +84,53 @@ def b2_fixture(): bucket_type="allPrivate", ) + # Create a test backup + test_backup_data = b"backup data" + upload_url = sim.get_upload_url(api_url, auth_token, bucket["bucketId"]) + stream = io.BytesIO(test_backup_data) + stream.seek(0) + + filename = TEST_BACKUP.name + sha1 = hashlib.sha1(test_backup_data).hexdigest() + file = sim.upload_file( + upload_url["uploadUrl"], + upload_url["authorizationToken"], + filename, + len(test_backup_data), + "application/octet-stream", + sha1, + BACKUP_METADATA, + stream, + ) + + def ls( + self, + prefix: str = "", + ) -> list[tuple[FileVersion, str]]: + """List files in the bucket.""" + return [ + ( + FileVersion( + sim, + file["fileId"], + file["fileName"], + file["contentLength"], + "application/octet-stream", + sha1, + BACKUP_METADATA, + file["uploadTimestamp"], + file["accountId"], + file["bucketId"], + "action", + None, + None, + ), + file["fileName"], + ) + ] + + BucketSimulator.ls = ls + yield BackblazeFixture(application_key_id, application_key, bucket, sim, auth) diff --git a/tests/components/backblaze_b2/test_backup.py b/tests/components/backblaze_b2/test_backup.py index cfbd3e84e1559..f826d602faba3 100644 --- a/tests/components/backblaze_b2/test_backup.py +++ b/tests/components/backblaze_b2/test_backup.py @@ -1,7 +1,8 @@ """Test Backblaze B2 backup agent.""" from collections.abc import AsyncGenerator -from unittest.mock import MagicMock, patch +import io +from unittest.mock import MagicMock, Mock, patch import pytest @@ -18,9 +19,10 @@ from homeassistant.setup import async_setup_component from . import setup_integration +from .const import BACKUP_METADATA, TEST_BACKUP from tests.common import MockConfigEntry -from tests.typing import WebSocketGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture(autouse=True) @@ -64,6 +66,188 @@ async def test_agents_info( } +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": TEST_BACKUP.addons, + "backup_id": TEST_BACKUP.backup_id, + "date": TEST_BACKUP.date, + "database_included": TEST_BACKUP.database_included, + "folders": TEST_BACKUP.folders, + "homeassistant_included": TEST_BACKUP.homeassistant_included, + "homeassistant_version": TEST_BACKUP.homeassistant_version, + "name": TEST_BACKUP.name, + "extra_metadata": TEST_BACKUP.extra_metadata, + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": TEST_BACKUP.protected, + "size": TEST_BACKUP.size, + } + }, + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + backup_id = TEST_BACKUP.backup_id + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "backup_id": backup_id, + "date": TEST_BACKUP.date, + "database_included": TEST_BACKUP.database_included, + "folders": TEST_BACKUP.folders, + "homeassistant_included": TEST_BACKUP.homeassistant_included, + "homeassistant_version": TEST_BACKUP.homeassistant_version, + "name": TEST_BACKUP.name, + "extra_metadata": TEST_BACKUP.extra_metadata, + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": TEST_BACKUP.protected, + "size": TEST_BACKUP.size, + } + }, + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + with patch("b2sdk.v2.FileVersion.download", return_value=Mock()) as mock_download: + + def iter_content(chunk_size: int = 1) -> io.BytesIO: + """Mock iter_content to return bytes.""" + return io.BytesIO(b"backup data") + + mock_download.return_value.response.iter_content = iter_content + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": io.StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {TEST_BACKUP.backup_id}" in caplog.text + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent delete backup.""" + with patch("b2sdk.v2.FileVersion.delete"): + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + +async def test_agents_error_on_download_not_found( + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + with patch( + "b2sdk._internal.raw_simulator.BucketSimulator.ls", + return_value=[], + ): + client = await hass_client() + backup_id = TEST_BACKUP.backup_id + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 404 + + +async def test_agents_delete_not_throwing_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent delete backup does not throw on a backup not found.""" + with patch( + "b2sdk._internal.raw_simulator.BucketSimulator.ls", + return_value=[], + ): + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: """Test listener gets cleaned up.""" listener = MagicMock() From ae127e3684d0d1163ec626bae2f4b9b40bb6fb03 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 23 May 2025 23:13:28 +0000 Subject: [PATCH 04/15] Update log-when-unavailable quality_scale --- homeassistant/components/backblaze_b2/quality_scale.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backblaze_b2/quality_scale.yaml b/homeassistant/components/backblaze_b2/quality_scale.yaml index 1153bd0d52fdf..24edbe88efe62 100644 --- a/homeassistant/components/backblaze_b2/quality_scale.yaml +++ b/homeassistant/components/backblaze_b2/quality_scale.yaml @@ -46,7 +46,9 @@ rules: status: exempt comment: This integration does not have entities. integration-owner: done - log-when-unavailable: todo + log-when-unavailable: + status: exempt + comment: This integration does not have entities. parallel-updates: status: exempt comment: This integration does not poll. From 1fbb2d85d716c124e15aeab7917c4fb7c0e5993c Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Mon, 26 May 2025 07:26:04 +0000 Subject: [PATCH 05/15] Rename backblaze_b2 to backblaze --- .strict-typing | 2 +- CODEOWNERS | 4 ++-- .../{backblaze_b2 => backblaze}/__init__.py | 6 +++--- .../components/{backblaze_b2 => backblaze}/backup.py | 4 ++-- .../{backblaze_b2 => backblaze}/config_flow.py | 6 +++--- .../components/{backblaze_b2 => backblaze}/const.py | 4 ++-- .../{backblaze_b2 => backblaze}/manifest.json | 6 +++--- .../{backblaze_b2 => backblaze}/quality_scale.yaml | 0 .../{backblaze_b2 => backblaze}/strings.json | 12 ++++++------ homeassistant/generated/config_flows.py | 2 +- homeassistant/generated/integrations.json | 4 ++-- mypy.ini | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../{backblaze_b2 => backblaze}/__init__.py | 4 ++-- .../{backblaze_b2 => backblaze}/conftest.py | 10 +++++----- .../components/{backblaze_b2 => backblaze}/const.py | 4 ++-- .../{backblaze_b2 => backblaze}/test_backup.py | 11 ++++------- .../{backblaze_b2 => backblaze}/test_config_flow.py | 4 ++-- .../{backblaze_b2 => backblaze}/test_init.py | 4 ++-- 20 files changed, 45 insertions(+), 48 deletions(-) rename homeassistant/components/{backblaze_b2 => backblaze}/__init__.py (94%) rename homeassistant/components/{backblaze_b2 => backblaze}/backup.py (98%) rename homeassistant/components/{backblaze_b2 => backblaze}/config_flow.py (95%) rename homeassistant/components/{backblaze_b2 => backblaze}/const.py (80%) rename homeassistant/components/{backblaze_b2 => backblaze}/manifest.json (80%) rename homeassistant/components/{backblaze_b2 => backblaze}/quality_scale.yaml (100%) rename homeassistant/components/{backblaze_b2 => backblaze}/strings.json (74%) rename tests/components/{backblaze_b2 => backblaze}/__init__.py (76%) rename tests/components/{backblaze_b2 => backblaze}/conftest.py (94%) rename tests/components/{backblaze_b2 => backblaze}/const.py (84%) rename tests/components/{backblaze_b2 => backblaze}/test_backup.py (97%) rename tests/components/{backblaze_b2 => backblaze}/test_config_flow.py (98%) rename tests/components/{backblaze_b2 => backblaze}/test_init.py (94%) diff --git a/.strict-typing b/.strict-typing index e1b3138995e33..6536bbabdf52c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -104,7 +104,7 @@ homeassistant.components.automation.* homeassistant.components.awair.* homeassistant.components.axis.* homeassistant.components.azure_storage.* -homeassistant.components.backblaze_b2.* +homeassistant.components.backblaze.* homeassistant.components.backup.* homeassistant.components.baf.* homeassistant.components.bang_olufsen.* diff --git a/CODEOWNERS b/CODEOWNERS index 8c673e2e41d70..fe4e53c3bfc0f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -184,8 +184,8 @@ build.json @home-assistant/supervisor /homeassistant/components/azure_service_bus/ @hfurubotten /homeassistant/components/azure_storage/ @zweckj /tests/components/azure_storage/ @zweckj -/homeassistant/components/backblaze_b2/ @hugo-vrijswijk -/tests/components/backblaze_b2/ @hugo-vrijswijk +/homeassistant/components/backblaze/ @hugo-vrijswijk +/tests/components/backblaze/ @hugo-vrijswijk /homeassistant/components/backup/ @home-assistant/core /tests/components/backup/ @home-assistant/core /homeassistant/components/baf/ @bdraco @jfroy diff --git a/homeassistant/components/backblaze_b2/__init__.py b/homeassistant/components/backblaze/__init__.py similarity index 94% rename from homeassistant/components/backblaze_b2/__init__.py rename to homeassistant/components/backblaze/__init__.py index 319cc09d422f6..0d300646459a5 100644 --- a/homeassistant/components/backblaze_b2/__init__.py +++ b/homeassistant/components/backblaze/__init__.py @@ -1,4 +1,4 @@ -"""The Backblaze B2 integration.""" +"""The Backblaze integration.""" from __future__ import annotations @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> bool: - """Set up Backblaze B2 from a config entry.""" + """Set up Backblaze from a config entry.""" info = InMemoryAccountInfo() b2_api = B2Api(info) @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> try: _LOGGER.info( - "Connecting to Backblaze B2 with application key id %s", + "Connecting to Backblaze with application key id %s", data[CONF_KEY_ID], ) await hass.async_add_executor_job( diff --git a/homeassistant/components/backblaze_b2/backup.py b/homeassistant/components/backblaze/backup.py similarity index 98% rename from homeassistant/components/backblaze_b2/backup.py rename to homeassistant/components/backblaze/backup.py index 714d2bac926cf..0f282d0060a81 100644 --- a/homeassistant/components/backblaze_b2/backup.py +++ b/homeassistant/components/backblaze/backup.py @@ -1,4 +1,4 @@ -"""Backup platform for the Backblaze B2 integration.""" +"""Backup platform for the Backblaze integration.""" from collections.abc import AsyncIterator, Callable, Coroutine import functools @@ -77,7 +77,7 @@ def remove_listener() -> None: class BackblazeBackupAgent(BackupAgent): - """Backup agent for Backblaze B2.""" + """Backup agent for Backblaze.""" domain = DOMAIN diff --git a/homeassistant/components/backblaze_b2/config_flow.py b/homeassistant/components/backblaze/config_flow.py similarity index 95% rename from homeassistant/components/backblaze_b2/config_flow.py rename to homeassistant/components/backblaze/config_flow.py index 66a7748196668..2efb840554f7e 100644 --- a/homeassistant/components/backblaze_b2/config_flow.py +++ b/homeassistant/components/backblaze/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for the Backblaze B2 integration.""" +"""Config flow for the Backblaze integration.""" from __future__ import annotations @@ -33,7 +33,7 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Backblaze B2.""" + """Handle a config flow for Backblaze.""" VERSION = 1 @@ -57,7 +57,7 @@ async def async_step_user( try: _LOGGER.info( - "Connecting to Backblaze B2 with application key id %s", + "Connecting to Backblaze with application key id %s", user_input[CONF_KEY_ID], ) await self.hass.async_add_executor_job( diff --git a/homeassistant/components/backblaze_b2/const.py b/homeassistant/components/backblaze/const.py similarity index 80% rename from homeassistant/components/backblaze_b2/const.py rename to homeassistant/components/backblaze/const.py index 9567be148fbc6..81e0035cb4466 100644 --- a/homeassistant/components/backblaze_b2/const.py +++ b/homeassistant/components/backblaze/const.py @@ -1,11 +1,11 @@ -"""Constants for the Backblaze B2 integration.""" +"""Constants for the Backblaze integration.""" from collections.abc import Callable from typing import Final from homeassistant.util.hass_dict import HassKey -DOMAIN: Final = "backblaze_b2" +DOMAIN: Final = "backblaze" CONF_KEY_ID = "key_id" CONF_APPLICATION_KEY = "application_key" diff --git a/homeassistant/components/backblaze_b2/manifest.json b/homeassistant/components/backblaze/manifest.json similarity index 80% rename from homeassistant/components/backblaze_b2/manifest.json rename to homeassistant/components/backblaze/manifest.json index d3fc5f696858e..c6cc3277b4aea 100644 --- a/homeassistant/components/backblaze_b2/manifest.json +++ b/homeassistant/components/backblaze/manifest.json @@ -1,9 +1,9 @@ { - "domain": "backblaze_b2", - "name": "Backblaze B2", + "domain": "backblaze", + "name": "Backblaze", "codeowners": ["@hugo-vrijswijk"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/backblaze_b2", + "documentation": "https://www.home-assistant.io/integrations/backblaze", "integration_type": "service", "iot_class": "cloud_push", "loggers": ["b2sdk"], diff --git a/homeassistant/components/backblaze_b2/quality_scale.yaml b/homeassistant/components/backblaze/quality_scale.yaml similarity index 100% rename from homeassistant/components/backblaze_b2/quality_scale.yaml rename to homeassistant/components/backblaze/quality_scale.yaml diff --git a/homeassistant/components/backblaze_b2/strings.json b/homeassistant/components/backblaze/strings.json similarity index 74% rename from homeassistant/components/backblaze_b2/strings.json rename to homeassistant/components/backblaze/strings.json index 27b7e1722fd96..bf6199feeea3f 100644 --- a/homeassistant/components/backblaze_b2/strings.json +++ b/homeassistant/components/backblaze/strings.json @@ -14,16 +14,16 @@ "bucket": "Bucket must already exist and be writable by the provided credentials.", "prefix": "Prefix folder to store back files in (no trailing slash). Leave empty to store in the root." }, - "title": "Add Backblaze B2 backup" + "title": "Add Backblaze backup" } }, "error": { - "restricted_bucket": "[%key:component::backblaze_b2::exceptions::restricted_bucket::message%]", + "restricted_bucket": "[%key:component::backblaze::exceptions::restricted_bucket::message%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_bucket_name": "[%key:component::backblaze_b2::exceptions::invalid_bucket_name::message%]", - "invalid_credentials": "[%key:component::backblaze_b2::exceptions::invalid_credentials::message%]", - "invalid_capability": "[%key:component::backblaze_b2::exceptions::invalid_capability::message%]", - "invalid_prefix": "[%key:component::backblaze_b2::exceptions::invalid_prefix::message%]", + "invalid_bucket_name": "[%key:component::backblaze::exceptions::invalid_bucket_name::message%]", + "invalid_credentials": "[%key:component::backblaze::exceptions::invalid_credentials::message%]", + "invalid_capability": "[%key:component::backblaze::exceptions::invalid_capability::message%]", + "invalid_prefix": "[%key:component::backblaze::exceptions::invalid_prefix::message%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 222a1a91a6f91..239ee8b7e39f7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -81,7 +81,7 @@ "azure_devops", "azure_event_hub", "azure_storage", - "backblaze_b2", + "backblaze", "baf", "balboa", "bang_olufsen", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 36016a1ee209c..5d2e7de5a8d3f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -617,8 +617,8 @@ "config_flow": true, "iot_class": "local_push" }, - "backblaze_b2": { - "name": "Backblaze B2", + "backblaze": { + "name": "Backblaze", "integration_type": "service", "config_flow": true, "iot_class": "cloud_push" diff --git a/mypy.ini b/mypy.ini index 3c93d33fe0e1b..8f2575654b309 100644 --- a/mypy.ini +++ b/mypy.ini @@ -795,7 +795,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.backblaze_b2.*] +[mypy-homeassistant.components.backblaze.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true diff --git a/requirements_all.txt b/requirements_all.txt index 52899a32bed20..2d8130d9ae3f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -578,7 +578,7 @@ azure-servicebus==7.10.0 # homeassistant.components.azure_storage azure-storage-blob==12.24.0 -# homeassistant.components.backblaze_b2 +# homeassistant.components.backblaze b2sdk==2.8.1 # homeassistant.components.holiday diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8502bb320ce93..b7f945ade85d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -524,7 +524,7 @@ azure-kusto-ingest==4.5.1 # homeassistant.components.azure_storage azure-storage-blob==12.24.0 -# homeassistant.components.backblaze_b2 +# homeassistant.components.backblaze b2sdk==2.8.1 # homeassistant.components.holiday diff --git a/tests/components/backblaze_b2/__init__.py b/tests/components/backblaze/__init__.py similarity index 76% rename from tests/components/backblaze_b2/__init__.py rename to tests/components/backblaze/__init__.py index 88d1c63a50a61..c2e225e1105fd 100644 --- a/tests/components/backblaze_b2/__init__.py +++ b/tests/components/backblaze/__init__.py @@ -1,4 +1,4 @@ -"""Tests for the Backblaze B2 integration.""" +"""Tests for the Backblaze integration.""" from homeassistant.core import HomeAssistant @@ -8,7 +8,7 @@ async def setup_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Set up the backblaze_b2 integration for testing.""" + """Set up the backblaze integration for testing.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/backblaze_b2/conftest.py b/tests/components/backblaze/conftest.py similarity index 94% rename from tests/components/backblaze_b2/conftest.py rename to tests/components/backblaze/conftest.py index 49d9efde01156..960e7f66fdf75 100644 --- a/tests/components/backblaze_b2/conftest.py +++ b/tests/components/backblaze/conftest.py @@ -1,4 +1,4 @@ -"""Common fixtures for the Backblaze B2 tests.""" +"""Common fixtures for the Backblaze tests.""" from collections.abc import Generator import hashlib @@ -10,7 +10,7 @@ from b2sdk.v2 import FileVersion, RawSimulator import pytest -from homeassistant.components.backblaze_b2.const import ( +from homeassistant.components.backblaze.const import ( CONF_APPLICATION_KEY, CONF_BUCKET, CONF_KEY_ID, @@ -26,7 +26,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( - "homeassistant.components.backblaze_b2.async_setup_entry", return_value=True + "homeassistant.components.backblaze.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry @@ -37,7 +37,7 @@ def b2_fixture(): sim = RawSimulator() with ( patch("b2sdk.v2.B2Api", return_value=sim) as mock_client, - patch("homeassistant.components.backblaze_b2.B2Api", return_value=sim), + patch("homeassistant.components.backblaze.B2Api", return_value=sim), ): RawSimulator.get_bucket_by_name = RawSimulator._get_bucket_by_name @@ -150,7 +150,7 @@ def mock_config_entry(b2_fixture: Any) -> MockConfigEntry: class BackblazeFixture: - """Mock Backblaze B2 account.""" + """Mock Backblaze account.""" def __init__( # noqa: D107 self, diff --git a/tests/components/backblaze_b2/const.py b/tests/components/backblaze/const.py similarity index 84% rename from tests/components/backblaze_b2/const.py rename to tests/components/backblaze/const.py index f290748177d94..71671d92174eb 100644 --- a/tests/components/backblaze_b2/const.py +++ b/tests/components/backblaze/const.py @@ -1,8 +1,8 @@ -"""Consts for Backblaze B2 tests.""" +"""Consts for Backblaze tests.""" from json import dumps -from homeassistant.components.backblaze_b2.const import CONF_BUCKET, CONF_PREFIX +from homeassistant.components.backblaze.const import CONF_BUCKET, CONF_PREFIX from homeassistant.components.backup import AgentBackup USER_INPUT = { diff --git a/tests/components/backblaze_b2/test_backup.py b/tests/components/backblaze/test_backup.py similarity index 97% rename from tests/components/backblaze_b2/test_backup.py rename to tests/components/backblaze/test_backup.py index f826d602faba3..87b4684de67d2 100644 --- a/tests/components/backblaze_b2/test_backup.py +++ b/tests/components/backblaze/test_backup.py @@ -1,4 +1,4 @@ -"""Test Backblaze B2 backup agent.""" +"""Test Backblaze backup agent.""" from collections.abc import AsyncGenerator import io @@ -6,13 +6,10 @@ import pytest -from homeassistant.components.backblaze_b2.backup import ( +from homeassistant.components.backblaze.backup import ( async_register_backup_agents_listener, ) -from homeassistant.components.backblaze_b2.const import ( - DATA_BACKUP_AGENT_LISTENERS, - DOMAIN, -) +from homeassistant.components.backblaze.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup @@ -30,7 +27,7 @@ async def setup_backup_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> AsyncGenerator[None]: - """Set up backblaze_b2 integration.""" + """Set up backblaze integration.""" with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), diff --git a/tests/components/backblaze_b2/test_config_flow.py b/tests/components/backblaze/test_config_flow.py similarity index 98% rename from tests/components/backblaze_b2/test_config_flow.py rename to tests/components/backblaze/test_config_flow.py index 8aae6c4160c0c..5065699cdbb8f 100644 --- a/tests/components/backblaze_b2/test_config_flow.py +++ b/tests/components/backblaze/test_config_flow.py @@ -1,11 +1,11 @@ -"""Test the Backblaze B2 config flow.""" +"""Test the Backblaze config flow.""" from unittest.mock import patch from b2sdk.v2 import exception from homeassistant import config_entries -from homeassistant.components.backblaze_b2.const import ( +from homeassistant.components.backblaze.const import ( CONF_APPLICATION_KEY, CONF_KEY_ID, DOMAIN, diff --git a/tests/components/backblaze_b2/test_init.py b/tests/components/backblaze/test_init.py similarity index 94% rename from tests/components/backblaze_b2/test_init.py rename to tests/components/backblaze/test_init.py index b37df88b8daeb..59ecd750b101b 100644 --- a/tests/components/backblaze_b2/test_init.py +++ b/tests/components/backblaze/test_init.py @@ -1,11 +1,11 @@ -"""Test the Backblaze B2 storage integration.""" +"""Test the Backblaze storage integration.""" from unittest.mock import patch from b2sdk.v2 import exception import pytest -from homeassistant.components.backblaze_b2.const import CONF_APPLICATION_KEY +from homeassistant.components.backblaze.const import CONF_APPLICATION_KEY from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant From b6aef5ab7bbc06871ff23bf25a292a998e34be21 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Mon, 26 May 2025 09:46:22 +0000 Subject: [PATCH 06/15] Wrap in single executor job --- .../components/backblaze/__init__.py | 18 +++++++++--------- .../components/backblaze/config_flow.py | 19 ++++++++++--------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/backblaze/__init__.py b/homeassistant/components/backblaze/__init__.py index 0d300646459a5..4a2463494926a 100644 --- a/homeassistant/components/backblaze/__init__.py +++ b/homeassistant/components/backblaze/__init__.py @@ -39,16 +39,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> "Connecting to Backblaze with application key id %s", data[CONF_KEY_ID], ) - await hass.async_add_executor_job( - b2_api.authorize_account, - "production", - data[CONF_KEY_ID], - data[CONF_APPLICATION_KEY], - ) - bucket = await hass.async_add_executor_job( - b2_api.get_bucket_by_name, data[CONF_BUCKET] - ) + def _authorize_and_get_bucket() -> Bucket: + b2_api.authorize_account( + "production", + data[CONF_KEY_ID], + data[CONF_APPLICATION_KEY], + ) + return b2_api.get_bucket_by_name(data[CONF_BUCKET]) + + bucket = await hass.async_add_executor_job(_authorize_and_get_bucket) except exception.Unauthorized as err: raise ConfigEntryError( diff --git a/homeassistant/components/backblaze/config_flow.py b/homeassistant/components/backblaze/config_flow.py index 2efb840554f7e..ac8798dabbf44 100644 --- a/homeassistant/components/backblaze/config_flow.py +++ b/homeassistant/components/backblaze/config_flow.py @@ -60,16 +60,17 @@ async def async_step_user( "Connecting to Backblaze with application key id %s", user_input[CONF_KEY_ID], ) - await self.hass.async_add_executor_job( - b2_api.authorize_account, - "production", - user_input[CONF_KEY_ID], - user_input[CONF_APPLICATION_KEY], - ) - await self.hass.async_add_executor_job( - b2_api.get_bucket_by_name, user_input[CONF_BUCKET] - ) + def authorize_and_get_bucket() -> None: + """Authorize account and get bucket by name.""" + b2_api.authorize_account( + "production", + user_input[CONF_KEY_ID], + user_input[CONF_APPLICATION_KEY], + ) + b2_api.get_bucket_by_name(user_input[CONF_BUCKET]) + + await self.hass.async_add_executor_job(authorize_and_get_bucket) allowed = b2_api.account_info.get_allowed() From 91601f9980a020b897d3a1d64fdaca07dc968d24 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Mon, 26 May 2025 11:31:34 +0000 Subject: [PATCH 07/15] Extract AgentBackup creation to method --- homeassistant/components/backblaze/backup.py | 21 +++++++++++--------- tests/components/backblaze/const.py | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/backblaze/backup.py b/homeassistant/components/backblaze/backup.py index 0f282d0060a81..2b1d49fefbf39 100644 --- a/homeassistant/components/backblaze/backup.py +++ b/homeassistant/components/backblaze/backup.py @@ -184,12 +184,9 @@ def get_files() -> None: if ( file.file_info is not None and file.file_info.get("metadata_version") == METADATA_VERSION + and file.file_info.get("backup_metadata") is not None ): - backups.append( - AgentBackup.from_dict( - json.loads(file.file_info["backup_metadata"]) - ) - ) + backups.append(self._backup_from_b2_file(file)) await self._hass.async_add_executor_job(get_files) _LOGGER.debug("Found %d backups", len(backups)) @@ -209,6 +206,7 @@ def find_file() -> FileVersion | None: file.file_info is not None and file.file_info.get("metadata_version") == METADATA_VERSION and file.file_info.get("backup_id") == backup_id + and file.file_info.get("backup_metadata") is not None ): _LOGGER.debug("Found file %s from id %s", file.file_name, backup_id) return file @@ -222,7 +220,12 @@ async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: file = await self._find_file_by_id(backup_id) if file is None: raise BackupNotFound(f"Backup {backup_id} not found") - metadata = file.file_info.get("backup_metadata") - if metadata is None: - raise BackupNotFound(f"Backup {backup_id} not found") - return AgentBackup.from_dict(json.loads(metadata)) + return self._backup_from_b2_file(file) + + def _backup_from_b2_file(self, file: FileVersion) -> AgentBackup: + metadata_str: str = file.file_info["backup_metadata"] + metadata = json.loads(metadata_str) + + metadata["size"] = file.size + metadata["name"] = file.file_name + return AgentBackup.from_dict(metadata) diff --git a/tests/components/backblaze/const.py b/tests/components/backblaze/const.py index 71671d92174eb..f7d62fb5526ac 100644 --- a/tests/components/backblaze/const.py +++ b/tests/components/backblaze/const.py @@ -22,7 +22,7 @@ homeassistant_version="2024.12.0.dev0", name="Core 2024.12.0.dev0", protected=False, - size=34519040, + size=11, ) BACKUP_METADATA = { From 31f0377128f3ddcc1ac21e4f98cc9a29efd66bcf Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Mon, 2 Jun 2025 09:35:19 +0000 Subject: [PATCH 08/15] Remove info log --- homeassistant/components/backblaze/__init__.py | 9 +-------- homeassistant/components/backblaze/config_flow.py | 4 ---- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/homeassistant/components/backblaze/__init__.py b/homeassistant/components/backblaze/__init__.py index 4a2463494926a..c5c697f609b63 100644 --- a/homeassistant/components/backblaze/__init__.py +++ b/homeassistant/components/backblaze/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import cast from b2sdk.v2 import B2Api, Bucket, InMemoryAccountInfo, exception @@ -20,9 +19,7 @@ DOMAIN, ) -type BackblazeConfigEntry = ConfigEntry[BackblazeConfig] - -_LOGGER = logging.getLogger(__name__) +type BackblazeConfigEntry = ConfigEntry[Bucket] async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> bool: @@ -35,10 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> prefix = data[CONF_PREFIX] try: - _LOGGER.info( - "Connecting to Backblaze with application key id %s", - data[CONF_KEY_ID], - ) def _authorize_and_get_bucket() -> Bucket: b2_api.authorize_account( diff --git a/homeassistant/components/backblaze/config_flow.py b/homeassistant/components/backblaze/config_flow.py index ac8798dabbf44..77dce63a4b56f 100644 --- a/homeassistant/components/backblaze/config_flow.py +++ b/homeassistant/components/backblaze/config_flow.py @@ -56,10 +56,6 @@ async def async_step_user( b2_api = B2Api(info) try: - _LOGGER.info( - "Connecting to Backblaze with application key id %s", - user_input[CONF_KEY_ID], - ) def authorize_and_get_bucket() -> None: """Authorize account and get bucket by name.""" From 622747a675928fee44c8893a16b6220a97ef1624 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Mon, 2 Jun 2025 11:14:42 +0000 Subject: [PATCH 09/15] Automatically add prefix slash --- homeassistant/components/backblaze/__init__.py | 15 +-------------- homeassistant/components/backblaze/backup.py | 6 +++--- homeassistant/components/backblaze/config_flow.py | 3 +++ homeassistant/components/backblaze/strings.json | 2 +- tests/components/backblaze/const.py | 2 +- tests/components/backblaze/test_config_flow.py | 15 +++++++++++++++ 6 files changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/backblaze/__init__.py b/homeassistant/components/backblaze/__init__.py index c5c697f609b63..f0b68f2c09561 100644 --- a/homeassistant/components/backblaze/__init__.py +++ b/homeassistant/components/backblaze/__init__.py @@ -14,7 +14,6 @@ CONF_APPLICATION_KEY, CONF_BUCKET, CONF_KEY_ID, - CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN, ) @@ -29,7 +28,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> b2_api = B2Api(info) data = cast(dict, entry.data) - prefix = data[CONF_PREFIX] try: @@ -72,10 +70,7 @@ def _authorize_and_get_bucket() -> Bucket: translation_key="invalid_auth", ) from err - if prefix and not prefix.endswith("/"): - prefix += "/" - - entry.runtime_data = BackblazeConfig(bucket, prefix) + entry.runtime_data = bucket def _async_notify_backup_listeners() -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): @@ -89,11 +84,3 @@ def _async_notify_backup_listeners() -> None: async def async_unload_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> bool: """Unload a config entry.""" return True - - -class BackblazeConfig: - """Small wrapper for Backblaze configuration.""" - - def __init__(self, bucket: Bucket, prefix: str) -> None: # noqa: D107 - self.bucket = bucket - self.prefix = prefix diff --git a/homeassistant/components/backblaze/backup.py b/homeassistant/components/backblaze/backup.py index 2b1d49fefbf39..940c0a8e74359 100644 --- a/homeassistant/components/backblaze/backup.py +++ b/homeassistant/components/backblaze/backup.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant, callback from . import BackblazeConfigEntry -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .const import CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) METADATA_VERSION = "1" @@ -86,8 +86,8 @@ def __init__(self, hass: HomeAssistant, entry: BackblazeConfigEntry) -> None: super().__init__() self._hass = hass self.async_create_task = entry.async_create_task - self._bucket = entry.runtime_data.bucket - self._prefix = entry.runtime_data.prefix + self._bucket = entry.runtime_data + self._prefix = entry.data[CONF_PREFIX] self.name = entry.title self.unique_id = entry.entry_id diff --git a/homeassistant/components/backblaze/config_flow.py b/homeassistant/components/backblaze/config_flow.py index 77dce63a4b56f..1bea6f86cdb19 100644 --- a/homeassistant/components/backblaze/config_flow.py +++ b/homeassistant/components/backblaze/config_flow.py @@ -90,6 +90,9 @@ def authorize_and_get_bucket() -> None: errors[CONF_PREFIX] = "invalid_prefix" placeholders["allowed_prefix"] = allowed_prefix + if prefix and not prefix.endswith("/"): + user_input[CONF_PREFIX] = f"{prefix}/" + except exception.Unauthorized: errors["base"] = "invalid_credentials" except exception.RestrictedBucket as err: diff --git a/homeassistant/components/backblaze/strings.json b/homeassistant/components/backblaze/strings.json index bf6199feeea3f..689fd77def7eb 100644 --- a/homeassistant/components/backblaze/strings.json +++ b/homeassistant/components/backblaze/strings.json @@ -12,7 +12,7 @@ "key_id": "Key ID to connect to Backblaze", "application_key": "Application key to connect to Backblaze", "bucket": "Bucket must already exist and be writable by the provided credentials.", - "prefix": "Prefix folder to store back files in (no trailing slash). Leave empty to store in the root." + "prefix": "Prefix folder to store back files in. Leave empty to store in the root." }, "title": "Add Backblaze backup" } diff --git a/tests/components/backblaze/const.py b/tests/components/backblaze/const.py index f7d62fb5526ac..f5858f24211eb 100644 --- a/tests/components/backblaze/const.py +++ b/tests/components/backblaze/const.py @@ -7,7 +7,7 @@ USER_INPUT = { CONF_BUCKET: "testBucket", - CONF_PREFIX: "testprefix", + CONF_PREFIX: "testprefix/", } diff --git a/tests/components/backblaze/test_config_flow.py b/tests/components/backblaze/test_config_flow.py index 5065699cdbb8f..e273449867347 100644 --- a/tests/components/backblaze/test_config_flow.py +++ b/tests/components/backblaze/test_config_flow.py @@ -52,6 +52,21 @@ async def test_flow(hass: HomeAssistant, b2_fixture: BackblazeFixture) -> None: assert result.get("data") == USER_INPUT +async def test_flow_adds_prefix( + hass: HomeAssistant, b2_fixture: BackblazeFixture +) -> None: + """Test config flow with prefix.""" + user_input = { + **USER_INPUT, + "prefix": "test-prefix/foo", + } + result = await _async_start_flow( + hass, b2_fixture.key_id, b2_fixture.application_key, user_input + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result["data"]["prefix"] == "test-prefix/foo/" + + async def test_abort_if_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 9acf1bdf32575e5210817cea85bbb1401ac0ebdd Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 6 Jun 2025 11:52:35 +0000 Subject: [PATCH 10/15] Remove cast --- homeassistant/components/backblaze/__init__.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backblaze/__init__.py b/homeassistant/components/backblaze/__init__.py index f0b68f2c09561..bcf20c47a32ff 100644 --- a/homeassistant/components/backblaze/__init__.py +++ b/homeassistant/components/backblaze/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import cast - from b2sdk.v2 import B2Api, Bucket, InMemoryAccountInfo, exception from homeassistant.config_entries import ConfigEntry @@ -27,17 +25,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> info = InMemoryAccountInfo() b2_api = B2Api(info) - data = cast(dict, entry.data) - try: def _authorize_and_get_bucket() -> Bucket: b2_api.authorize_account( "production", - data[CONF_KEY_ID], - data[CONF_APPLICATION_KEY], + entry.data[CONF_KEY_ID], + entry.data[CONF_APPLICATION_KEY], ) - return b2_api.get_bucket_by_name(data[CONF_BUCKET]) + return b2_api.get_bucket_by_name(entry.data[CONF_BUCKET]) bucket = await hass.async_add_executor_job(_authorize_and_get_bucket) From ebb9246e20873d08e7ad1c845c4b308c1e1dc1ee Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 13 Jun 2025 10:18:31 +0000 Subject: [PATCH 11/15] Move some code outside try-catch --- .../components/backblaze/__init__.py | 17 ++++--- .../components/backblaze/config_flow.py | 46 +++++++++---------- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/backblaze/__init__.py b/homeassistant/components/backblaze/__init__.py index bcf20c47a32ff..925d1a454aaa6 100644 --- a/homeassistant/components/backblaze/__init__.py +++ b/homeassistant/components/backblaze/__init__.py @@ -25,16 +25,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> info = InMemoryAccountInfo() b2_api = B2Api(info) - try: - - def _authorize_and_get_bucket() -> Bucket: - b2_api.authorize_account( - "production", - entry.data[CONF_KEY_ID], - entry.data[CONF_APPLICATION_KEY], - ) - return b2_api.get_bucket_by_name(entry.data[CONF_BUCKET]) + def _authorize_and_get_bucket() -> Bucket: + b2_api.authorize_account( + "production", + entry.data[CONF_KEY_ID], + entry.data[CONF_APPLICATION_KEY], + ) + return b2_api.get_bucket_by_name(entry.data[CONF_BUCKET]) + try: bucket = await hass.async_add_executor_job(_authorize_and_get_bucket) except exception.Unauthorized as err: diff --git a/homeassistant/components/backblaze/config_flow.py b/homeassistant/components/backblaze/config_flow.py index 1bea6f86cdb19..ef46cb0bb4402 100644 --- a/homeassistant/components/backblaze/config_flow.py +++ b/homeassistant/components/backblaze/config_flow.py @@ -55,19 +55,30 @@ async def async_step_user( info = InMemoryAccountInfo() b2_api = B2Api(info) - try: - - def authorize_and_get_bucket() -> None: - """Authorize account and get bucket by name.""" - b2_api.authorize_account( - "production", - user_input[CONF_KEY_ID], - user_input[CONF_APPLICATION_KEY], - ) - b2_api.get_bucket_by_name(user_input[CONF_BUCKET]) + def _authorize_and_get_bucket() -> None: + """Authorize account and get bucket by name.""" + b2_api.authorize_account( + "production", + user_input[CONF_KEY_ID], + user_input[CONF_APPLICATION_KEY], + ) + b2_api.get_bucket_by_name(user_input[CONF_BUCKET]) - await self.hass.async_add_executor_job(authorize_and_get_bucket) + try: + await self.hass.async_add_executor_job(_authorize_and_get_bucket) + except exception.Unauthorized: + errors["base"] = "invalid_credentials" + except exception.RestrictedBucket as err: + placeholders["restricted_bucket_name"] = err.bucket_name + errors[CONF_BUCKET] = "restricted_bucket" + except exception.NonExistentBucket: + errors[CONF_BUCKET] = "invalid_bucket_name" + except exception.ConnectionReset: + errors["base"] = "cannot_connect" + except exception.MissingAccountData: + errors["base"] = "invalid_credentials" + else: allowed = b2_api.account_info.get_allowed() # Check if capabilities contains 'writeFiles' and 'listFiles' and 'deleteFiles' and 'readFiles' @@ -84,6 +95,7 @@ def authorize_and_get_bucket() -> None: ): errors["base"] = "invalid_capability" + # Check if prefix is valid prefix: str = user_input[CONF_PREFIX] allowed_prefix = cast(str, allowed.get("namePrefix", "")) if allowed_prefix and not prefix.startswith(allowed_prefix): @@ -93,18 +105,6 @@ def authorize_and_get_bucket() -> None: if prefix and not prefix.endswith("/"): user_input[CONF_PREFIX] = f"{prefix}/" - except exception.Unauthorized: - errors["base"] = "invalid_credentials" - except exception.RestrictedBucket as err: - placeholders["restricted_bucket_name"] = err.bucket_name - errors[CONF_BUCKET] = "restricted_bucket" - except exception.NonExistentBucket: - errors[CONF_BUCKET] = "invalid_bucket_name" - except exception.ConnectionReset: - errors["base"] = "cannot_connect" - except exception.MissingAccountData: - errors["base"] = "invalid_credentials" - else: if not errors: return self.async_create_entry( title=user_input[CONF_BUCKET], data=user_input From caf3d01ed05660bce6eb5877372718cd30f08941 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 13 Jun 2025 10:18:54 +0000 Subject: [PATCH 12/15] Update translation string for prefix directory path --- homeassistant/components/backblaze/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/backblaze/strings.json b/homeassistant/components/backblaze/strings.json index 689fd77def7eb..7f703397617b8 100644 --- a/homeassistant/components/backblaze/strings.json +++ b/homeassistant/components/backblaze/strings.json @@ -12,7 +12,7 @@ "key_id": "Key ID to connect to Backblaze", "application_key": "Application key to connect to Backblaze", "bucket": "Bucket must already exist and be writable by the provided credentials.", - "prefix": "Prefix folder to store back files in. Leave empty to store in the root." + "prefix": "Directory path to store backup files in. Leave empty to store in the root." }, "title": "Add Backblaze backup" } From 9e919d8da7c8cc39ecf51a6f75cbb63a3360aa90 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 13 Jun 2025 10:19:26 +0000 Subject: [PATCH 13/15] Use unique entry_id --- tests/components/backblaze/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/backblaze/conftest.py b/tests/components/backblaze/conftest.py index 960e7f66fdf75..ac21176654f38 100644 --- a/tests/components/backblaze/conftest.py +++ b/tests/components/backblaze/conftest.py @@ -138,7 +138,7 @@ def ls( def mock_config_entry(b2_fixture: Any) -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - entry_id="test", + entry_id="c6dd4663ec2c75fe04701be54c03f27b", title="test", domain=DOMAIN, data={ From ef4a560feaf5bb8731786c4d941dd68205e6286e Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 13 Jun 2025 10:19:47 +0000 Subject: [PATCH 14/15] Update imports and assert step_id and errors --- tests/components/backblaze/test_config_flow.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/components/backblaze/test_config_flow.py b/tests/components/backblaze/test_config_flow.py index e273449867347..cc92dabca2b32 100644 --- a/tests/components/backblaze/test_config_flow.py +++ b/tests/components/backblaze/test_config_flow.py @@ -4,12 +4,12 @@ from b2sdk.v2 import exception -from homeassistant import config_entries from homeassistant.components.backblaze.const import ( CONF_APPLICATION_KEY, CONF_KEY_ID, DOMAIN, ) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -24,7 +24,7 @@ async def _async_start_flow( key_id: str, application_key: str, user_input: dict[str, str] | None = None, -) -> config_entries.ConfigFlowResult: +) -> ConfigFlowResult: """Initialize the config flow.""" if user_input is None: user_input = USER_INPUT @@ -32,9 +32,11 @@ async def _async_start_flow( user_input[CONF_KEY_ID] = key_id user_input[CONF_APPLICATION_KEY] = application_key result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {} return await hass.config_entries.flow.async_configure( result["flow_id"], From bcf47f163a0a60a6f7cfac9046bb1e529aeffaf4 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Fri, 13 Jun 2025 10:24:30 +0000 Subject: [PATCH 15/15] Fix test --- tests/components/backblaze/test_backup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/backblaze/test_backup.py b/tests/components/backblaze/test_backup.py index 87b4684de67d2..cbd30b2ee055d 100644 --- a/tests/components/backblaze/test_backup.py +++ b/tests/components/backblaze/test_backup.py @@ -95,6 +95,8 @@ async def test_agents_list_backups( }, "failed_agent_ids": [], "with_automatic_settings": None, + "failed_addons": [], + "failed_folders": [], } ] @@ -131,6 +133,8 @@ async def test_agents_get_backup( }, "failed_agent_ids": [], "with_automatic_settings": None, + "failed_addons": [], + "failed_folders": [], }