From c07a9e9d593aa57976426ed29e85bc8d00640bfc Mon Sep 17 00:00:00 2001 From: Allen Porter <allen@thebends.org> Date: Tue, 3 Sep 2024 04:54:43 -0700 Subject: [PATCH] Add dependency on google-photos-library-api: Change the Google Photos client library to a new external package (#125040) * Change the Google Photos client library to a new external package * Remove mime type guessing * Update tests to mock out the client library and iterators * Update homeassistant/components/google_photos/media_source.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> --- .../components/google_photos/__init__.py | 13 +- homeassistant/components/google_photos/api.py | 198 ++---------------- .../components/google_photos/config_flow.py | 15 +- .../components/google_photos/exceptions.py | 7 - .../components/google_photos/manifest.json | 4 +- .../components/google_photos/media_source.py | 105 +++++----- .../components/google_photos/services.py | 44 +++- .../components/google_photos/strings.json | 6 + .../components/google_photos/types.py | 7 + requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- tests/components/google_photos/conftest.py | 127 +++++++---- .../fixtures/api_not_enabled_response.json | 17 -- .../google_photos/fixtures/list_albums.json | 1 + .../google_photos/fixtures/not_dict.json | 1 - .../google_photos/test_config_flow.py | 45 ++-- .../google_photos/test_media_source.py | 58 ++--- .../components/google_photos/test_services.py | 51 ++--- 18 files changed, 288 insertions(+), 419 deletions(-) delete mode 100644 homeassistant/components/google_photos/exceptions.py create mode 100644 homeassistant/components/google_photos/types.py delete mode 100644 tests/components/google_photos/fixtures/api_not_enabled_response.json delete mode 100644 tests/components/google_photos/fixtures/not_dict.json diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index ee02c695f16..950995e72c0 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -3,17 +3,17 @@ from __future__ import annotations from aiohttp import ClientError, ClientResponseError +from google_photos_library_api.api import GooglePhotosLibraryApi -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import api from .const import DOMAIN from .services import async_register_services - -type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] +from .types import GooglePhotosConfigEntry __all__ = [ "DOMAIN", @@ -29,8 +29,9 @@ async def async_setup_entry( hass, entry ) ) - session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth(hass, session) + web_session = async_get_clientsession(hass) + oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = api.AsyncConfigEntryAuth(web_session, oauth_session) try: await auth.async_get_access_token() except ClientResponseError as err: @@ -41,7 +42,7 @@ async def async_setup_entry( raise ConfigEntryNotReady from err except ClientError as err: raise ConfigEntryNotReady from err - entry.runtime_data = auth + entry.runtime_data = GooglePhotosLibraryApi(auth) async_register_services(hass) diff --git a/homeassistant/components/google_photos/api.py b/homeassistant/components/google_photos/api.py index 0bbb2fe162b..35878efd792 100644 --- a/homeassistant/components/google_photos/api.py +++ b/homeassistant/components/google_photos/api.py @@ -1,216 +1,44 @@ """API for Google Photos bound to Home Assistant OAuth.""" -from abc import ABC, abstractmethod -from functools import partial -import logging -from typing import Any, cast +from typing import cast -from aiohttp.client_exceptions import ClientError -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build -from googleapiclient.errors import HttpError -from googleapiclient.http import HttpRequest +import aiohttp +from google_photos_library_api import api from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import config_entry_oauth2_flow -from .exceptions import GooglePhotosApiError -_LOGGER = logging.getLogger(__name__) - -DEFAULT_PAGE_SIZE = 20 - -# Only included necessary fields to limit response sizes -GET_MEDIA_ITEM_FIELDS = ( - "id,baseUrl,mimeType,filename,mediaMetadata(width,height,photo,video)" -) -LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})" -UPLOAD_API = "https://photoslibrary.googleapis.com/v1/uploads" -LIST_ALBUMS_FIELDS = ( - "nextPageToken,albums(id,title,coverPhotoBaseUrl,coverPhotoMediaItemId)" -) - - -class AuthBase(ABC): - """Base class for Google Photos authentication library. - - Provides an asyncio interface around the blocking client library. - """ - - def __init__( - self, - hass: HomeAssistant, - ) -> None: - """Initialize Google Photos auth.""" - self._hass = hass - - @abstractmethod - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - - async def get_user_info(self) -> dict[str, Any]: - """Get the user profile info.""" - service = await self._get_profile_service() - cmd: HttpRequest = service.userinfo().get() - return await self._execute(cmd) - - async def get_media_item(self, media_item_id: str) -> dict[str, Any]: - """Get all MediaItem resources.""" - service = await self._get_photos_service() - cmd: HttpRequest = service.mediaItems().get( - mediaItemId=media_item_id, fields=GET_MEDIA_ITEM_FIELDS - ) - return await self._execute(cmd) - - async def list_media_items( - self, - page_size: int | None = None, - page_token: str | None = None, - album_id: str | None = None, - favorites: bool = False, - ) -> dict[str, Any]: - """Get all MediaItem resources.""" - service = await self._get_photos_service() - args: dict[str, Any] = { - "pageSize": (page_size or DEFAULT_PAGE_SIZE), - "pageToken": page_token, - } - cmd: HttpRequest - if album_id is not None or favorites: - if album_id is not None: - args["albumId"] = album_id - if favorites: - args["filters"] = {"featureFilter": {"includedFeatures": "FAVORITES"}} - cmd = service.mediaItems().search(body=args, fields=LIST_MEDIA_ITEM_FIELDS) - else: - cmd = service.mediaItems().list(**args, fields=LIST_MEDIA_ITEM_FIELDS) - return await self._execute(cmd) - - async def list_albums( - self, page_size: int | None = None, page_token: str | None = None - ) -> dict[str, Any]: - """Get all Album resources.""" - service = await self._get_photos_service() - cmd: HttpRequest = service.albums().list( - pageSize=(page_size or DEFAULT_PAGE_SIZE), - pageToken=page_token, - fields=LIST_ALBUMS_FIELDS, - ) - return await self._execute(cmd) - - async def upload_content(self, content: bytes, mime_type: str) -> str: - """Upload media content to the API and return an upload token.""" - token = await self.async_get_access_token() - session = aiohttp_client.async_get_clientsession(self._hass) - try: - result = await session.post( - UPLOAD_API, headers=_upload_headers(token, mime_type), data=content - ) - result.raise_for_status() - return await result.text() - except ClientError as err: - raise GooglePhotosApiError(f"Failed to upload content: {err}") from err - - async def create_media_items(self, upload_tokens: list[str]) -> list[str]: - """Create a batch of media items and return the ids.""" - service = await self._get_photos_service() - cmd: HttpRequest = service.mediaItems().batchCreate( - body={ - "newMediaItems": [ - { - "simpleMediaItem": { - "uploadToken": upload_token, - } - for upload_token in upload_tokens - } - ] - } - ) - result = await self._execute(cmd) - return [ - media_item["mediaItem"]["id"] - for media_item in result["newMediaItemResults"] - ] - - async def _get_photos_service(self) -> Resource: - """Get current photos library API resource.""" - token = await self.async_get_access_token() - return await self._hass.async_add_executor_job( - partial( - build, - "photoslibrary", - "v1", - credentials=Credentials(token=token), # type: ignore[no-untyped-call] - static_discovery=False, - ) - ) - - async def _get_profile_service(self) -> Resource: - """Get current profile service API resource.""" - token = await self.async_get_access_token() - return await self._hass.async_add_executor_job( - partial(build, "oauth2", "v2", credentials=Credentials(token=token)) # type: ignore[no-untyped-call] - ) - - async def _execute(self, request: HttpRequest) -> dict[str, Any]: - try: - result = await self._hass.async_add_executor_job(request.execute) - except HttpError as err: - raise GooglePhotosApiError( - f"Google Photos API responded with error ({err.status_code}): {err.reason}" - ) from err - if not isinstance(result, dict): - raise GooglePhotosApiError( - f"Google Photos API replied with unexpected response: {result}" - ) - if error := result.get("error"): - message = error.get("message", "Unknown Error") - raise GooglePhotosApiError(f"Google Photos API response: {message}") - return cast(dict[str, Any], result) - - -class AsyncConfigEntryAuth(AuthBase): +class AsyncConfigEntryAuth(api.AbstractAuth): """Provide Google Photos authentication tied to an OAuth2 based config entry.""" def __init__( self, - hass: HomeAssistant, + websession: aiohttp.ClientSession, oauth_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize AsyncConfigEntryAuth.""" - super().__init__(hass) - self._oauth_session = oauth_session + super().__init__(websession) + self._session = oauth_session async def async_get_access_token(self) -> str: """Return a valid access token.""" - if not self._oauth_session.valid_token: - await self._oauth_session.async_ensure_token_valid() - return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) + await self._session.async_ensure_token_valid() + return cast(str, self._session.token[CONF_ACCESS_TOKEN]) -class AsyncConfigFlowAuth(AuthBase): +class AsyncConfigFlowAuth(api.AbstractAuth): """An API client used during the config flow with a fixed token.""" def __init__( self, - hass: HomeAssistant, + websession: aiohttp.ClientSession, token: str, ) -> None: """Initialize ConfigFlowAuth.""" - super().__init__(hass) + super().__init__(websession) self._token = token async def async_get_access_token(self) -> str: """Return a valid access token.""" return self._token - - -def _upload_headers(token: str, mime_type: str) -> dict[str, Any]: - """Create the upload headers.""" - return { - "Authorization": f"Bearer {token}", - "Content-Type": "application/octet-stream", - "X-Goog-Upload-Content-Type": mime_type, - "X-Goog-Upload-Protocol": "raw", - } diff --git a/homeassistant/components/google_photos/config_flow.py b/homeassistant/components/google_photos/config_flow.py index e5378f67ffd..6b025cac6be 100644 --- a/homeassistant/components/google_photos/config_flow.py +++ b/homeassistant/components/google_photos/config_flow.py @@ -4,13 +4,15 @@ from collections.abc import Mapping import logging from typing import Any +from google_photos_library_api.api import GooglePhotosLibraryApi +from google_photos_library_api.exceptions import GooglePhotosApiError + from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import GooglePhotosConfigEntry, api from .const import DOMAIN, OAUTH2_SCOPES -from .exceptions import GooglePhotosApiError class OAuth2FlowHandler( @@ -39,7 +41,10 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the flow.""" - client = api.AsyncConfigFlowAuth(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + session = aiohttp_client.async_get_clientsession(self.hass) + auth = api.AsyncConfigFlowAuth(session, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + client = GooglePhotosLibraryApi(auth) + try: user_resource_info = await client.get_user_info() await client.list_media_items(page_size=1) @@ -51,7 +56,7 @@ class OAuth2FlowHandler( except Exception: self.logger.exception("Unknown error occurred") return self.async_abort(reason="unknown") - user_id = user_resource_info["id"] + user_id = user_resource_info.id if self.reauth_entry: if self.reauth_entry.unique_id == user_id: @@ -62,7 +67,7 @@ class OAuth2FlowHandler( await self.async_set_unique_id(user_id) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user_resource_info["name"], data=data) + return self.async_create_entry(title=user_resource_info.name, data=data) async def async_step_reauth( self, entry_data: Mapping[str, Any] diff --git a/homeassistant/components/google_photos/exceptions.py b/homeassistant/components/google_photos/exceptions.py deleted file mode 100644 index b1a40688677..00000000000 --- a/homeassistant/components/google_photos/exceptions.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Exceptions for Google Photos api calls.""" - -from homeassistant.exceptions import HomeAssistantError - - -class GooglePhotosApiError(HomeAssistantError): - """Error talking to the Google Photos API.""" diff --git a/homeassistant/components/google_photos/manifest.json b/homeassistant/components/google_photos/manifest.json index 3fefb6cf610..5ff37135f9a 100644 --- a/homeassistant/components/google_photos/manifest.json +++ b/homeassistant/components/google_photos/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/google_photos", "iot_class": "cloud_polling", - "loggers": ["googleapiclient"], - "requirements": ["google-api-python-client==2.71.0"] + "loggers": ["google_photos_library_api"], + "requirements": ["google-photos-library-api==0.8.0"] } diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index a709dd66a0a..63d66d5a82b 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -5,6 +5,9 @@ from enum import Enum, StrEnum import logging from typing import Any, Self, cast +from google_photos_library_api.exceptions import GooglePhotosApiError +from google_photos_library_api.model import Album, MediaItem + from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source import ( BrowseError, @@ -17,17 +20,12 @@ from homeassistant.core import HomeAssistant from . import GooglePhotosConfigEntry from .const import DOMAIN, READ_SCOPES -from .exceptions import GooglePhotosApiError _LOGGER = logging.getLogger(__name__) -# Media Sources do not support paging, so we only show a subset of recent -# photos when displaying the users library. We fetch a minimum of 50 photos -# unless we run out, but in pages of 100 at a time given sometimes responses -# may only contain a handful of items Fetches at least 50 photos. -MAX_RECENT_PHOTOS = 50 -MAX_ALBUMS = 50 -PAGE_SIZE = 100 +MAX_RECENT_PHOTOS = 100 +MEDIA_ITEMS_PAGE_SIZE = 100 +ALBUM_PAGE_SIZE = 50 THUMBNAIL_SIZE = 256 LARGE_IMAGE_SIZE = 2160 @@ -158,14 +156,15 @@ class GooglePhotosMediaSource(MediaSource): entry = self._async_config_entry(identifier.config_entry_id) client = entry.runtime_data media_item = await client.get_media_item(media_item_id=identifier.media_id) - is_video = media_item["mediaMetadata"].get("video") is not None + if not media_item.mime_type: + raise BrowseError("Could not determine mime type of media item") + if media_item.media_metadata and (media_item.media_metadata.video is not None): + url = _video_url(media_item) + else: + url = _media_url(media_item, LARGE_IMAGE_SIZE) return PlayMedia( - url=( - _video_url(media_item) - if is_video - else _media_url(media_item, LARGE_IMAGE_SIZE) - ), - mime_type=media_item["mimeType"], + url=url, + mime_type=media_item.mime_type, ) async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: @@ -199,7 +198,6 @@ class GooglePhotosMediaSource(MediaSource): source = _build_account(entry, identifier) if identifier.id_type is None: - result = await client.list_albums(page_size=MAX_ALBUMS) source.children = [ _build_album( special_album.value.title, @@ -208,17 +206,27 @@ class GooglePhotosMediaSource(MediaSource): ), ) for special_album in SpecialAlbum - ] + [ + ] + albums: list[Album] = [] + try: + async for album_result in await client.list_albums( + page_size=ALBUM_PAGE_SIZE + ): + albums.extend(album_result.albums) + except GooglePhotosApiError as err: + raise BrowseError(f"Error listing albums: {err}") from err + + source.children.extend( _build_album( - album["title"], + album.title, PhotosIdentifier.album( identifier.config_entry_id, - album["id"], + album.id, ), _cover_photo_url(album, THUMBNAIL_SIZE), ) - for album in result["albums"] - ] + for album in albums + ) return source if ( @@ -233,28 +241,24 @@ class GooglePhotosMediaSource(MediaSource): else: list_args = {"album_id": identifier.media_id} - media_items: list[dict[str, Any]] = [] - page_token: str | None = None - while ( - not special_album - or (max_photos := special_album.value.max_photos) is None - or len(media_items) < max_photos - ): - try: - result = await client.list_media_items( - **list_args, page_size=PAGE_SIZE, page_token=page_token - ) - except GooglePhotosApiError as err: - raise BrowseError(f"Error listing media items: {err}") from err - media_items.extend(result["mediaItems"]) - page_token = result.get("nextPageToken") - if page_token is None: - break + media_items: list[MediaItem] = [] + try: + async for media_item_result in await client.list_media_items( + **list_args, page_size=MEDIA_ITEMS_PAGE_SIZE + ): + media_items.extend(media_item_result.media_items) + if ( + special_album + and (max_photos := special_album.value.max_photos) + and len(media_items) > max_photos + ): + break + except GooglePhotosApiError as err: + raise BrowseError(f"Error listing media items: {err}") from err - # Render the grid of media item results source.children = [ _build_media_item( - PhotosIdentifier.photo(identifier.config_entry_id, media_item["id"]), + PhotosIdentifier.photo(identifier.config_entry_id, media_item.id), media_item, ) for media_item in media_items @@ -315,38 +319,41 @@ def _build_album( def _build_media_item( - identifier: PhotosIdentifier, media_item: dict[str, Any] + identifier: PhotosIdentifier, + media_item: MediaItem, ) -> BrowseMediaSource: """Build the node for an individual photo or video.""" - is_video = media_item["mediaMetadata"].get("video") is not None + is_video = media_item.media_metadata and ( + media_item.media_metadata.video is not None + ) return BrowseMediaSource( domain=DOMAIN, identifier=identifier.as_string(), media_class=MediaClass.IMAGE if not is_video else MediaClass.VIDEO, media_content_type=MediaType.IMAGE if not is_video else MediaType.VIDEO, - title=media_item["filename"], + title=media_item.filename, can_play=is_video, can_expand=False, thumbnail=_media_url(media_item, THUMBNAIL_SIZE), ) -def _media_url(media_item: dict[str, Any], max_size: int) -> str: +def _media_url(media_item: MediaItem, max_size: int) -> str: """Return a media item url with the specified max thumbnail size on the longest edge. See https://developers.google.com/photos/library/guides/access-media-items#base-urls """ - return f"{media_item["baseUrl"]}=h{max_size}" + return f"{media_item.base_url}=h{max_size}" -def _video_url(media_item: dict[str, Any]) -> str: +def _video_url(media_item: MediaItem) -> str: """Return a video url for the item. See https://developers.google.com/photos/library/guides/access-media-items#base-urls """ - return f"{media_item["baseUrl"]}=dv" + return f"{media_item.base_url}=dv" -def _cover_photo_url(album: dict[str, Any], max_size: int) -> str: +def _cover_photo_url(album: Album, max_size: int) -> str: """Return a media item url for the cover photo of the album.""" - return f"{album["coverPhotoBaseUrl"]}=h{max_size}" + return f"{album.cover_photo_base_url}=h{max_size}" diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index 77015d5c700..66aa61e23a4 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -6,9 +6,10 @@ import asyncio import mimetypes from pathlib import Path +from google_photos_library_api.exceptions import GooglePhotosApiError +from google_photos_library_api.model import NewMediaItem, SimpleMediaItem import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILENAME from homeassistant.core import ( HomeAssistant, @@ -19,14 +20,8 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv -from . import api from .const import DOMAIN, UPLOAD_SCOPE - -type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] - -__all__ = [ - "DOMAIN", -] +from .types import GooglePhotosConfigEntry CONF_CONFIG_ENTRY_ID = "config_entry_id" @@ -98,11 +93,38 @@ def async_register_services(hass: HomeAssistant) -> None: ) for mime_type, content in file_results: upload_tasks.append(client_api.upload_content(content, mime_type)) - upload_tokens = await asyncio.gather(*upload_tasks) - media_ids = await client_api.create_media_items(upload_tokens) + try: + upload_results = await asyncio.gather(*upload_tasks) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_error", + translation_placeholders={"message": str(err)}, + ) from err + try: + upload_result = await client_api.create_media_items( + [ + NewMediaItem( + SimpleMediaItem(upload_token=upload_result.upload_token) + ) + for upload_result in upload_results + ] + ) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": str(err)}, + ) from err if call.return_response: return { - "media_items": [{"media_item_id": media_id for media_id in media_ids}] + "media_items": [ + { + "media_item_id": item_result.media_item.id + for item_result in upload_result.new_media_item_results + if item_result.media_item and item_result.media_item.id + } + ] } return None diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index 9e88429124e..bf2809f896f 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -45,6 +45,12 @@ }, "missing_upload_permission": { "message": "Home Assistnt was not granted permission to upload to Google Photos" + }, + "upload_error": { + "message": "Failed to upload content: {message}" + }, + "api_error": { + "message": "Google Photos API responded with error: {message}" } }, "services": { diff --git a/homeassistant/components/google_photos/types.py b/homeassistant/components/google_photos/types.py new file mode 100644 index 00000000000..2fe57fe1d15 --- /dev/null +++ b/homeassistant/components/google_photos/types.py @@ -0,0 +1,7 @@ +"""Google Photos types.""" + +from google_photos_library_api.api import GooglePhotosLibraryApi + +from homeassistant.config_entries import ConfigEntry + +type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosLibraryApi] diff --git a/requirements_all.txt b/requirements_all.txt index da902149cfd..19d99787672 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -979,7 +979,6 @@ goalzero==0.2.2 goodwe==0.3.6 # homeassistant.components.google_mail -# homeassistant.components.google_photos # homeassistant.components.google_tasks google-api-python-client==2.71.0 @@ -995,6 +994,9 @@ google-generativeai==0.7.2 # homeassistant.components.nest google-nest-sdm==5.0.0 +# homeassistant.components.google_photos +google-photos-library-api==0.8.0 + # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67986dd3a53..6203d295d78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -829,7 +829,6 @@ goalzero==0.2.2 goodwe==0.3.6 # homeassistant.components.google_mail -# homeassistant.components.google_photos # homeassistant.components.google_tasks google-api-python-client==2.71.0 @@ -845,6 +844,9 @@ google-generativeai==0.7.2 # homeassistant.components.nest google-nest-sdm==5.0.0 +# homeassistant.components.google_photos +google-photos-library-api==0.8.0 + # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index f7289993258..9dbe85bd25b 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -1,10 +1,18 @@ """Test fixtures for Google Photos.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator import time from typing import Any -from unittest.mock import Mock, patch - +from unittest.mock import AsyncMock, Mock, patch + +from google_photos_library_api.api import GooglePhotosLibraryApi +from google_photos_library_api.model import ( + Album, + ListAlbumResult, + ListMediaItemResult, + MediaItem, + UserInfoResult, +) import pytest from homeassistant.components.application_credentials import ( @@ -28,6 +36,12 @@ CLIENT_SECRET = "5678" FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" EXPIRES_IN = 3600 +USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" +PHOTOS_BASE_URL = "https://photoslibrary.googleapis.com" +MEDIA_ITEMS_URL = f"{PHOTOS_BASE_URL}/v1/mediaItems" +ALBUMS_URL = f"{PHOTOS_BASE_URL}/v1/albums" +UPLOADS_URL = f"{PHOTOS_BASE_URL}/v1/uploads" +CREATE_MEDIA_ITEMS_URL = f"{PHOTOS_BASE_URL}/v1/mediaItems:batchCreate" @pytest.fixture(name="expires_at") @@ -100,56 +114,83 @@ def mock_user_identifier() -> str | None: return USER_IDENTIFIER -@pytest.fixture(name="setup_api") -def mock_setup_api( - fixture_name: str, user_identifier: str -) -> Generator[Mock, None, None]: - """Set up fake Google Photos API responses from fixtures.""" - with patch("homeassistant.components.google_photos.api.build") as mock: - mock.return_value.userinfo.return_value.get.return_value.execute.return_value = { - "id": user_identifier, - "name": "Test Name", - } - - responses = ( - load_json_array_fixture(fixture_name, DOMAIN) if fixture_name else [] - ) - - queue = list(responses) - - def list_media_items(**kwargs: Any) -> Mock: - mock = Mock() - mock.execute.return_value = queue.pop(0) - return mock - - mock.return_value.mediaItems.return_value.list = list_media_items - mock.return_value.mediaItems.return_value.search = list_media_items +@pytest.fixture(name="api_error") +def mock_api_error() -> Exception | None: + """Provide a json fixture file to load for list media item api responses.""" + return None - # Mock a point lookup by reading contents of the fixture above - def get_media_item(mediaItemId: str, **kwargs: Any) -> Mock: - for response in responses: - for media_item in response["mediaItems"]: - if media_item["id"] == mediaItemId: - mock = Mock() - mock.execute.return_value = media_item - return mock - return None - mock.return_value.mediaItems.return_value.get = get_media_item - mock.return_value.albums.return_value.list.return_value.execute.return_value = ( - load_json_object_fixture("list_albums.json", DOMAIN) - ) +@pytest.fixture(name="mock_api") +def mock_client_api( + fixture_name: str, + user_identifier: str, + api_error: Exception, +) -> Generator[Mock, None, None]: + """Set up fake Google Photos API responses from fixtures.""" + mock_api = AsyncMock(GooglePhotosLibraryApi, autospec=True) + mock_api.get_user_info.return_value = UserInfoResult( + id=user_identifier, + name="Test Name", + email="test.name@gmail.com", + ) - yield mock + responses = load_json_array_fixture(fixture_name, DOMAIN) if fixture_name else [] + + async def list_media_items( + *args: Any, + ) -> AsyncGenerator[ListMediaItemResult, None, None]: + for response in responses: + mock_list_media_items = Mock(ListMediaItemResult) + mock_list_media_items.media_items = [ + MediaItem.from_dict(media_item) for media_item in response["mediaItems"] + ] + yield mock_list_media_items + + mock_api.list_media_items.return_value.__aiter__ = list_media_items + mock_api.list_media_items.return_value.__anext__ = list_media_items + mock_api.list_media_items.side_effect = api_error + + # Mock a point lookup by reading contents of the fixture above + async def get_media_item(media_item_id: str, **kwargs: Any) -> Mock: + for response in responses: + for media_item in response["mediaItems"]: + if media_item["id"] == media_item_id: + return MediaItem.from_dict(media_item) + return None + + mock_api.get_media_item = get_media_item + + # Emulate an async iterator for returning pages of response objects. We just + # return a single page. + + async def list_albums( + *args: Any, **kwargs: Any + ) -> AsyncGenerator[ListAlbumResult, None, None]: + mock_list_album_result = Mock(ListAlbumResult) + mock_list_album_result.albums = [ + Album.from_dict(album) + for album in load_json_object_fixture("list_albums.json", DOMAIN)["albums"] + ] + yield mock_list_album_result + + mock_api.list_albums.return_value.__aiter__ = list_albums + mock_api.list_albums.return_value.__anext__ = list_albums + mock_api.list_albums.side_effect = api_error + return mock_api @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry, + mock_api: Mock, ) -> Callable[[], Awaitable[bool]]: """Fixture to set up the integration.""" config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.google_photos.GooglePhotosLibraryApi", + return_value=mock_api, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/google_photos/fixtures/api_not_enabled_response.json b/tests/components/google_photos/fixtures/api_not_enabled_response.json deleted file mode 100644 index 8933fcdc7bd..00000000000 --- a/tests/components/google_photos/fixtures/api_not_enabled_response.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "error": { - "code": 403, - "message": "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", - "errors": [ - { - "message": "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", - "domain": "usageLimits", - "reason": "accessNotConfigured", - "extendedHelp": "https://console.developers.google.com" - } - ], - "status": "PERMISSION_DENIED" - } - } -] diff --git a/tests/components/google_photos/fixtures/list_albums.json b/tests/components/google_photos/fixtures/list_albums.json index 57f2873715b..7460e1d36f3 100644 --- a/tests/components/google_photos/fixtures/list_albums.json +++ b/tests/components/google_photos/fixtures/list_albums.json @@ -3,6 +3,7 @@ { "id": "album-media-id-1", "title": "Album title", + "productUrl": "http://photos.google.com/album-media-id-1", "isWriteable": true, "mediaItemsCount": 7, "coverPhotoBaseUrl": "http://img.example.com/id3", diff --git a/tests/components/google_photos/fixtures/not_dict.json b/tests/components/google_photos/fixtures/not_dict.json deleted file mode 100644 index 05e325337d2..00000000000 --- a/tests/components/google_photos/fixtures/not_dict.json +++ /dev/null @@ -1 +0,0 @@ -["not a dictionary"] diff --git a/tests/components/google_photos/test_config_flow.py b/tests/components/google_photos/test_config_flow.py index 2564a8ed134..be97d7658c6 100644 --- a/tests/components/google_photos/test_config_flow.py +++ b/tests/components/google_photos/test_config_flow.py @@ -4,8 +4,7 @@ from collections.abc import Generator from typing import Any from unittest.mock import Mock, patch -from googleapiclient.errors import HttpError -from httplib2 import Response +from google_photos_library_api.exceptions import GooglePhotosApiError import pytest from homeassistant import config_entries @@ -20,7 +19,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from .conftest import EXPIRES_IN, FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, USER_IDENTIFIER -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -37,6 +36,16 @@ def mock_setup_entry() -> Generator[Mock, None, None]: yield mock_setup +@pytest.fixture(autouse=True) +def mock_patch_api(mock_api: Mock) -> Generator[None, None, None]: + """Fixture to patch the config flow api.""" + with patch( + "homeassistant.components.google_photos.config_flow.GooglePhotosLibraryApi", + return_value=mock_api, + ): + yield + + @pytest.fixture(name="updated_token_entry", autouse=True) def mock_updated_token_entry() -> dict[str, Any]: """Fixture to provide any test specific overrides to token data from the oauth token endpoint.""" @@ -60,7 +69,7 @@ def mock_token_request( ) -@pytest.mark.usefixtures("current_request_with_host", "setup_api") +@pytest.mark.usefixtures("current_request_with_host", "mock_api") @pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) async def test_full_flow( hass: HomeAssistant, @@ -126,11 +135,17 @@ async def test_full_flow( @pytest.mark.usefixtures( "current_request_with_host", "setup_credentials", + "mock_api", +) +@pytest.mark.parametrize( + "api_error", + [ + GooglePhotosApiError("some error"), + ], ) async def test_api_not_enabled( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - setup_api: Mock, ) -> None: """Check flow aborts if api is not enabled.""" result = await hass.config_entries.flow.async_init( @@ -160,24 +175,18 @@ async def test_api_not_enabled( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - setup_api.return_value.mediaItems.return_value.list = Mock() - setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = HttpError( - Response({"status": "403"}), - bytes(load_fixture("google_photos/api_not_enabled_response.json"), "utf-8"), - ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "access_not_configured" - assert result["description_placeholders"]["message"].endswith( - "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." - ) + assert result["description_placeholders"]["message"].endswith("some error") @pytest.mark.usefixtures("current_request_with_host", "setup_credentials") async def test_general_exception( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, + mock_api: Mock, ) -> None: """Check flow aborts if exception happens.""" result = await hass.config_entries.flow.async_init( @@ -206,17 +215,15 @@ async def test_general_exception( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - with patch( - "homeassistant.components.google_photos.api.build", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + mock_api.list_media_items.side_effect = Exception + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown" -@pytest.mark.usefixtures("current_request_with_host", "setup_api", "setup_integration") +@pytest.mark.usefixtures("current_request_with_host", "mock_api", "setup_integration") @pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) @pytest.mark.parametrize( "updated_token_entry", diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index 1028a34aec1..762a4d5ebd1 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -1,10 +1,8 @@ """Test the Google Photos media source.""" -from typing import Any from unittest.mock import Mock -from googleapiclient.errors import HttpError -from httplib2 import Response +from google_photos_library_api.exceptions import GooglePhotosApiError import pytest from homeassistant.components.google_photos.const import DOMAIN, UPLOAD_SCOPE @@ -46,7 +44,7 @@ async def test_no_config_entries( assert not browse.children -@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.usefixtures("setup_integration", "mock_api") @pytest.mark.parametrize( ("scopes"), [ @@ -64,7 +62,7 @@ async def test_no_read_scopes( assert not browse.children -@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.usefixtures("setup_integration", "mock_api") @pytest.mark.parametrize( ("album_path", "expected_album_title"), [ @@ -135,14 +133,14 @@ async def test_browse_albums( ] == expected_medias -@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.usefixtures("setup_integration", "mock_api") async def test_invalid_config_entry(hass: HomeAssistant) -> None: """Test browsing to a config entry that does not exist.""" with pytest.raises(BrowseError, match="Could not find config entry"): await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/invalid-config-entry") -@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.usefixtures("setup_integration", "mock_api") @pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) async def test_browse_invalid_path(hass: HomeAssistant) -> None: """Test browsing to a photo is not possible.""" @@ -161,8 +159,8 @@ async def test_browse_invalid_path(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_integration") -@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) -async def test_invalid_album_id(hass: HomeAssistant, setup_api: Mock) -> None: +@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) +async def test_invalid_album_id(hass: HomeAssistant, mock_api: Mock) -> None: """Test browsing to an album id that does not exist.""" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN @@ -172,11 +170,6 @@ async def test_invalid_album_id(hass: HomeAssistant, setup_api: Mock) -> None: (CONFIG_ENTRY_ID, "Account Name") ] - setup_api.return_value.mediaItems.return_value.search = Mock() - setup_api.return_value.mediaItems.return_value.search.return_value.execute.side_effect = HttpError( - Response({"status": "404"}), b"" - ) - with pytest.raises(BrowseError, match="Error listing media items"): await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/invalid-album-id" @@ -201,18 +194,9 @@ async def test_missing_photo_id( await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/{identifier}", None) -@pytest.mark.usefixtures("setup_integration", "setup_api") -@pytest.mark.parametrize( - "side_effect", - [ - HttpError(Response({"status": "403"}), b""), - ], -) -async def test_list_media_items_failure( - hass: HomeAssistant, - setup_api: Any, - side_effect: HttpError | Response, -) -> None: +@pytest.mark.usefixtures("setup_integration", "mock_api") +@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) +async def test_list_albums_failure(hass: HomeAssistant) -> None: """Test browsing to an album id that does not exist.""" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN @@ -222,24 +206,13 @@ async def test_list_media_items_failure( (CONFIG_ENTRY_ID, "Account Name") ] - setup_api.return_value.mediaItems.return_value.list = Mock() - setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = side_effect - - with pytest.raises(BrowseError, match="Error listing media items"): - await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" - ) + with pytest.raises(BrowseError, match="Error listing albums"): + await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}") -@pytest.mark.usefixtures("setup_integration", "setup_api") -@pytest.mark.parametrize( - "fixture_name", - [ - "api_not_enabled_response.json", - "not_dict.json", - ], -) -async def test_media_items_error_parsing_response(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("setup_integration", "mock_api") +@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) +async def test_list_media_items_failure(hass: HomeAssistant) -> None: """Test browsing to an album id that does not exist.""" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN @@ -248,6 +221,7 @@ async def test_media_items_error_parsing_response(hass: HomeAssistant) -> None: assert [(child.identifier, child.title) for child in browse.children] == [ (CONFIG_ENTRY_ID, "Account Name") ] + with pytest.raises(BrowseError, match="Error listing media items"): await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py index 198de3295a9..10d57e1d178 100644 --- a/tests/components/google_photos/test_services.py +++ b/tests/components/google_photos/test_services.py @@ -1,45 +1,42 @@ """Tests for Google Photos.""" -import http from unittest.mock import Mock, patch -from googleapiclient.errors import HttpError -from httplib2 import Response +from google_photos_library_api.exceptions import GooglePhotosApiError +from google_photos_library_api.model import ( + CreateMediaItemsResult, + MediaItem, + NewMediaItemResult, + Status, +) import pytest -from homeassistant.components.google_photos.api import UPLOAD_API from homeassistant.components.google_photos.const import DOMAIN, READ_SCOPES from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.usefixtures("setup_integration") async def test_upload_service( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - setup_api: Mock, + mock_api: Mock, ) -> None: """Test service call to upload content.""" assert hass.services.has_service(DOMAIN, "upload") - aioclient_mock.post(UPLOAD_API, text="some-upload-token") - setup_api.return_value.mediaItems.return_value.batchCreate.return_value.execute.return_value = { - "newMediaItemResults": [ - { - "status": { - "code": 200, - }, - "mediaItem": { - "id": "new-media-item-id-1", - }, - } + mock_api.create_media_items.return_value = CreateMediaItemsResult( + new_media_item_results=[ + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-1"), + ) ] - } + ) with ( patch( @@ -62,6 +59,7 @@ async def test_upload_service( blocking=True, return_response=True, ) + assert response == {"media_items": [{"media_item_id": "new-media-item-id-1"}]} @@ -157,12 +155,11 @@ async def test_filename_does_not_exist( async def test_upload_service_upload_content_failure( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - setup_api: Mock, + mock_api: Mock, ) -> None: """Test service call to upload content.""" - aioclient_mock.post(UPLOAD_API, status=http.HTTPStatus.SERVICE_UNAVAILABLE) + mock_api.upload_content.side_effect = GooglePhotosApiError() with ( patch( @@ -192,15 +189,11 @@ async def test_upload_service_upload_content_failure( async def test_upload_service_fails_create( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - setup_api: Mock, + mock_api: Mock, ) -> None: """Test service call to upload content.""" - aioclient_mock.post(UPLOAD_API, text="some-upload-token") - setup_api.return_value.mediaItems.return_value.batchCreate.return_value.execute.side_effect = HttpError( - Response({"status": "403"}), b"" - ) + mock_api.create_media_items.side_effect = GooglePhotosApiError() with ( patch( @@ -238,8 +231,6 @@ async def test_upload_service_fails_create( async def test_upload_service_no_scope( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - setup_api: Mock, ) -> None: """Test service call to upload content but the config entry is read-only.""" -- GitLab