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
parent
b9db9eeab2
commit
c07a9e9d59
|
@ -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)
|
||||
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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."""
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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]
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
|
@ -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",
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
["not a dictionary"]
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
Loading…
Reference in New Issue