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>
pull/125146/head
Allen Porter 2024-09-03 04:54:43 -07:00 committed by GitHub
parent b9db9eeab2
commit c07a9e9d59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 281 additions and 412 deletions

View File

@ -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)

View File

@ -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 .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)"
)
from homeassistant.helpers import config_entry_oauth2_flow
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",
}

View File

@ -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]

View File

@ -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."""

View File

@ -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"]
}

View File

@ -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
):
media_items: list[MediaItem] = []
try:
result = await client.list_media_items(
**list_args, page_size=PAGE_SIZE, page_token=page_token
)
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
media_items.extend(result["mediaItems"])
page_token = result.get("nextPageToken")
if page_token is None:
break
# 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}"

View File

@ -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

View File

@ -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": {

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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
# 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
@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.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)
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()

View File

@ -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"
}
}
]

View File

@ -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",

View File

@ -1 +0,0 @@
["not a dictionary"]

View File

@ -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,
):
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",

View File

@ -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"

View File

@ -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."""