Add Google Photos integration (#124835)

* Add Google Photos integration

* Mark credentials typing

* Add code review suggestions to simpilfy google_photos

* Update tests/components/google_photos/conftest.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Fix comment typo

* Update test fixtures from review feedback

* Remove unnecessary test for services

* Remove keyword argument

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Allen Porter 2024-08-30 07:27:19 -07:00 committed by GitHub
parent 5e93394ae7
commit c01bb44757
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1321 additions and 0 deletions

View file

@ -209,6 +209,7 @@ homeassistant.components.glances.*
homeassistant.components.goalzero.*
homeassistant.components.google.*
homeassistant.components.google_assistant_sdk.*
homeassistant.components.google_photos.*
homeassistant.components.google_sheets.*
homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.*

View file

@ -554,6 +554,8 @@ build.json @home-assistant/supervisor
/tests/components/google_generative_ai_conversation/ @tronikos
/homeassistant/components/google_mail/ @tkdrob
/tests/components/google_mail/ @tkdrob
/homeassistant/components/google_photos/ @allenporter
/tests/components/google_photos/ @allenporter
/homeassistant/components/google_sheets/ @tkdrob
/tests/components/google_sheets/ @tkdrob
/homeassistant/components/google_tasks/ @allenporter

View file

@ -9,6 +9,7 @@
"google_generative_ai_conversation",
"google_mail",
"google_maps",
"google_photos",
"google_pubsub",
"google_sheets",
"google_tasks",

View file

@ -0,0 +1,45 @@
"""The Google Photos integration."""
from __future__ import annotations
from aiohttp import ClientError, ClientResponseError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow
from . import api
from .const import DOMAIN
type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth]
__all__ = [
"DOMAIN",
]
async def async_setup_entry(
hass: HomeAssistant, entry: GooglePhotosConfigEntry
) -> bool:
"""Set up Google Photos from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
auth = api.AsyncConfigEntryAuth(hass, session)
try:
await auth.async_get_access_token()
except (ClientResponseError, ClientError) as err:
raise ConfigEntryNotReady from err
entry.runtime_data = auth
return True
async def async_unload_entry(
hass: HomeAssistant, entry: GooglePhotosConfigEntry
) -> bool:
"""Unload a config entry."""
return True

View file

@ -0,0 +1,143 @@
"""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 google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from googleapiclient.errors import HttpError
from googleapiclient.http import BatchHttpRequest, HttpRequest
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
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})"
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(
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
) -> dict[str, Any]:
"""Get all MediaItem resources."""
service = await self._get_photos_service()
cmd: HttpRequest = service.mediaItems().list(
pageSize=(page_size or DEFAULT_PAGE_SIZE),
pageToken=page_token,
fields=LIST_MEDIA_ITEM_FIELDS,
)
return await self._execute(cmd)
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 | BatchHttpRequest) -> 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):
"""Provide Google Photos authentication tied to an OAuth2 based config entry."""
def __init__(
self,
hass: HomeAssistant,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize AsyncConfigEntryAuth."""
super().__init__(hass)
self._oauth_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])
class AsyncConfigFlowAuth(AuthBase):
"""An API client used during the config flow with a fixed token."""
def __init__(
self,
hass: HomeAssistant,
token: str,
) -> None:
"""Initialize ConfigFlowAuth."""
super().__init__(hass)
self._token = token
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
return self._token

View file

@ -0,0 +1,23 @@
"""application_credentials platform the Google Photos integration."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
authorize_url=OAUTH2_AUTHORIZE,
token_url=OAUTH2_TOKEN,
)
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
return {
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
"more_info_url": "https://www.home-assistant.io/integrations/google_photos/",
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
}

View file

@ -0,0 +1,54 @@
"""Config flow for Google Photos."""
import logging
from typing import Any
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow
from . import api
from .const import DOMAIN, OAUTH2_SCOPES
from .exceptions import GooglePhotosApiError
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Google Photos OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
return {
"scope": " ".join(OAUTH2_SCOPES),
# Add params to ensure we get back a refresh token
"access_type": "offline",
"prompt": "consent",
}
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])
try:
user_resource_info = await client.get_user_info()
await client.list_media_items()
except GooglePhotosApiError as ex:
return self.async_abort(
reason="access_not_configured",
description_placeholders={"message": str(ex)},
)
except Exception:
self.logger.exception("Unknown error occurred")
return self.async_abort(reason="unknown")
user_id = user_resource_info["id"]
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)

View file

@ -0,0 +1,10 @@
"""Constants for the Google Photos integration."""
DOMAIN = "google_photos"
OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth"
OAUTH2_TOKEN = "https://oauth2.googleapis.com/token"
OAUTH2_SCOPES = [
"https://www.googleapis.com/auth/photoslibrary.readonly",
"https://www.googleapis.com/auth/userinfo.profile",
]

View file

@ -0,0 +1,7 @@
"""Exceptions for Google Photos api calls."""
from homeassistant.exceptions import HomeAssistantError
class GooglePhotosApiError(HomeAssistantError):
"""Error talking to the Google Photos API."""

View file

@ -0,0 +1,10 @@
{
"domain": "google_photos",
"name": "Google Photos",
"codeowners": ["@allenporter"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/google_photos",
"iot_class": "cloud_polling",
"requirements": ["google-api-python-client==2.71.0"]
}

View file

@ -0,0 +1,283 @@
"""Media source for Google Photos."""
from dataclasses import dataclass
import logging
from typing import Any, cast
from homeassistant.components.media_player import MediaClass, MediaType
from homeassistant.components.media_source import (
BrowseError,
BrowseMediaSource,
MediaSource,
MediaSourceItem,
PlayMedia,
)
from homeassistant.core import HomeAssistant
from . import GooglePhotosConfigEntry
from .const import DOMAIN
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_PHOTOS = 50
PAGE_SIZE = 100
THUMBNAIL_SIZE = 256
LARGE_IMAGE_SIZE = 2048
# Markers for parts of PhotosIdentifier url pattern.
# The PhotosIdentifier can be in the following forms:
# config-entry-id
# config-entry-id/a/album-media-id
# config-entry-id/p/photo-media-id
#
# The album-media-id can contain special reserved folder names for use by
# this integration for virtual folders like the `recent` album.
PHOTO_SOURCE_IDENTIFIER_PHOTO = "p"
PHOTO_SOURCE_IDENTIFIER_ALBUM = "a"
# Currently supports a single album of recent photos
RECENT_PHOTOS_ALBUM = "recent"
RECENT_PHOTOS_TITLE = "Recent Photos"
@dataclass
class PhotosIdentifier:
"""Google Photos item identifier in a media source URL."""
config_entry_id: str
"""Identifies the account for the media item."""
album_media_id: str | None = None
"""Identifies the album contents to show.
Not present at the same time as `photo_media_id`.
"""
photo_media_id: str | None = None
"""Identifies an indiviidual photo or video.
Not present at the same time as `album_media_id`.
"""
def as_string(self) -> str:
"""Serialize the identiifer as a string.
This is the opposite if parse_identifier().
"""
if self.photo_media_id is None:
if self.album_media_id is None:
return self.config_entry_id
return f"{self.config_entry_id}/{PHOTO_SOURCE_IDENTIFIER_ALBUM}/{self.album_media_id}"
return f"{self.config_entry_id}/{PHOTO_SOURCE_IDENTIFIER_PHOTO}/{self.photo_media_id}"
def parse_identifier(identifier: str) -> PhotosIdentifier:
"""Parse a PhotosIdentifier form a string.
This is the opposite of as_string().
"""
parts = identifier.split("/")
if len(parts) == 1:
return PhotosIdentifier(parts[0])
if len(parts) != 3:
raise BrowseError(f"Invalid identifier: {identifier}")
if parts[1] == PHOTO_SOURCE_IDENTIFIER_PHOTO:
return PhotosIdentifier(parts[0], photo_media_id=parts[2])
return PhotosIdentifier(parts[0], album_media_id=parts[2])
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
"""Set up Synology media source."""
return GooglePhotosMediaSource(hass)
class GooglePhotosMediaSource(MediaSource):
"""Provide Google Photos as media sources."""
name = "Google Photos"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize Google Photos source."""
super().__init__(DOMAIN)
self.hass = hass
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media identifier to a url.
This will resolve a specific media item to a url for the full photo or video contents.
"""
identifier = parse_identifier(item.identifier)
if identifier.photo_media_id is None:
raise BrowseError(
f"Could not resolve identifier without a photo_media_id: {identifier}"
)
entry = self._async_config_entry(identifier.config_entry_id)
client = entry.runtime_data
media_item = await client.get_media_item(
media_item_id=identifier.photo_media_id
)
is_video = media_item["mediaMetadata"].get("video") is not None
return PlayMedia(
url=(
_video_url(media_item)
if is_video
else _media_url(media_item, LARGE_IMAGE_SIZE)
),
mime_type=media_item["mimeType"],
)
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
"""Return details about the media source.
This renders the multi-level album structure for an account, its albums,
or the contents of an album. This will return a BrowseMediaSource with a
single level of children at the next level of the hierarchy.
"""
if not item.identifier:
# Top level view that lists all accounts.
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title="Google Photos",
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=[
_build_account(entry, PhotosIdentifier(cast(str, entry.unique_id)))
for entry in self.hass.config_entries.async_loaded_entries(DOMAIN)
],
)
# Determine the configuration entry for this item
identifier = parse_identifier(item.identifier)
entry = self._async_config_entry(identifier.config_entry_id)
client = entry.runtime_data
if identifier.album_media_id is None:
source = _build_account(entry, identifier)
source.children = [
_build_album(
RECENT_PHOTOS_TITLE,
PhotosIdentifier(
identifier.config_entry_id, album_media_id=RECENT_PHOTOS_ALBUM
),
)
]
return source
# Currently only supports listing a single album of recent photos.
if identifier.album_media_id != RECENT_PHOTOS_ALBUM:
raise BrowseError(f"Unsupported album: {identifier}")
# Fetch recent items
media_items: list[dict[str, Any]] = []
page_token: str | None = None
while len(media_items) < MAX_PHOTOS:
try:
result = await client.list_media_items(
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
# Render the grid of media item results
source = _build_account(entry, PhotosIdentifier(cast(str, entry.unique_id)))
source.children = [
_build_media_item(
PhotosIdentifier(
identifier.config_entry_id, photo_media_id=media_item["id"]
),
media_item,
)
for media_item in media_items
]
return source
def _async_config_entry(self, config_entry_id: str) -> GooglePhotosConfigEntry:
"""Return a config entry with the specified id."""
entry = self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, config_entry_id
)
if not entry:
raise BrowseError(
f"Could not find config entry for identifier: {config_entry_id}"
)
return entry
def _build_account(
config_entry: GooglePhotosConfigEntry,
identifier: PhotosIdentifier,
) -> BrowseMediaSource:
"""Build the root node for a Google Photos account for a config entry."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=identifier.as_string(),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title=config_entry.title,
can_play=False,
can_expand=True,
)
def _build_album(title: str, identifier: PhotosIdentifier) -> BrowseMediaSource:
"""Build an album node."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=identifier.as_string(),
media_class=MediaClass.ALBUM,
media_content_type=MediaClass.ALBUM,
title=title,
can_play=False,
can_expand=True,
)
def _build_media_item(
identifier: PhotosIdentifier, media_item: dict[str, Any]
) -> BrowseMediaSource:
"""Build the node for an individual photos or video."""
is_video = media_item["mediaMetadata"].get("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"],
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:
"""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
"""
width = media_item["mediaMetadata"]["width"]
height = media_item["mediaMetadata"]["height"]
key = "h" if height > width else "w"
return f"{media_item["baseUrl"]}={key}{max_size}"
def _video_url(media_item: dict[str, Any]) -> 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"

View file

@ -0,0 +1,29 @@
{
"application_credentials": {
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Photos. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n"
},
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"access_not_configured": "Unable to access the Google API:\n\n{message}",
"unknown": "[%key:common::config_flow::error::unknown%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
}
}

View file

@ -10,6 +10,7 @@ APPLICATION_CREDENTIALS = [
"google",
"google_assistant_sdk",
"google_mail",
"google_photos",
"google_sheets",
"google_tasks",
"home_connect",

View file

@ -224,6 +224,7 @@ FLOWS = {
"google_assistant_sdk",
"google_generative_ai_conversation",
"google_mail",
"google_photos",
"google_sheets",
"google_tasks",
"google_translate",

View file

@ -2280,6 +2280,12 @@
"iot_class": "cloud_polling",
"name": "Google Maps"
},
"google_photos": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Google Photos"
},
"google_pubsub": {
"integration_type": "hub",
"config_flow": false,

View file

@ -1846,6 +1846,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.google_photos.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.google_sheets.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View file

@ -979,6 +979,7 @@ 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

View file

@ -829,6 +829,7 @@ 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

View file

@ -0,0 +1 @@
"""Tests for the Google Photos integration."""

View file

@ -0,0 +1,121 @@
"""Test fixtures for Google Photos."""
from collections.abc import Awaitable, Callable, Generator
import time
from typing import Any
from unittest.mock import Mock, patch
import pytest
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.google_photos.const import DOMAIN, OAUTH2_SCOPES
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_json_array_fixture
USER_IDENTIFIER = "user-identifier-1"
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
FAKE_ACCESS_TOKEN = "some-access-token"
FAKE_REFRESH_TOKEN = "some-refresh-token"
@pytest.fixture(name="expires_at")
def mock_expires_at() -> int:
"""Fixture to set the oauth token expiration time."""
return time.time() + 3600
@pytest.fixture(name="token_entry")
def mock_token_entry(expires_at: int) -> dict[str, Any]:
"""Fixture for OAuth 'token' data for a ConfigEntry."""
return {
"access_token": FAKE_ACCESS_TOKEN,
"refresh_token": FAKE_REFRESH_TOKEN,
"scope": " ".join(OAUTH2_SCOPES),
"token_type": "Bearer",
"expires_at": expires_at,
}
@pytest.fixture(name="config_entry")
def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry:
"""Fixture for a config entry."""
return MockConfigEntry(
domain=DOMAIN,
unique_id="config-entry-id-123",
data={
"auth_implementation": DOMAIN,
"token": token_entry,
},
title="Account Name",
)
@pytest.fixture(autouse=True)
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
@pytest.fixture(name="fixture_name")
def mock_fixture_name() -> str | None:
"""Provide a json fixture file to load for list media item api responses."""
return None
@pytest.fixture(name="setup_api")
def mock_setup_api(fixture_name: 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 a point lookup by reading contents of the fixture above
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:
mock = Mock()
mock.execute.return_value = media_item
return mock
return None
mock.return_value.mediaItems.return_value.get = get_media_item
yield mock
@pytest.fixture(name="setup_integration")
async def mock_setup_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> 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()

View file

@ -0,0 +1,17 @@
[
{
"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

@ -0,0 +1,35 @@
[
{
"mediaItems": [
{
"id": "id1",
"description": "some-descripton",
"productUrl": "http://example.com/id1",
"baseUrl": "http://img.example.com/id1",
"mimeType": "image/jpeg",
"mediaMetadata": {
"creationTime": "2014-10-02T15:01:23Z",
"width": 1600,
"height": 768
},
"filename": "example1.jpg"
},
{
"id": "id2",
"description": "some-descripton",
"productUrl": "http://example.com/id2",
"baseUrl": "http://img.example.com/id2",
"mimeType": "video/mp4",
"mediaMetadata": {
"creationTime": "2014-10-02T16:01:23Z",
"width": 1600,
"height": 768,
"video": {
"cameraMake": "Pixel"
}
},
"filename": "example2.mp4"
}
]
}
]

View file

@ -0,0 +1,5 @@
[
{
"mediaItems": []
}
]

View file

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

View file

@ -0,0 +1,205 @@
"""Test the Google Photos config flow."""
from unittest.mock import Mock, patch
from googleapiclient.errors import HttpError
from httplib2 import Response
import pytest
from homeassistant import config_entries
from homeassistant.components.google_photos.const import (
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from .conftest import USER_IDENTIFIER
from tests.common import load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
@pytest.mark.usefixtures("current_request_with_host", "setup_api")
@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"])
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Check full flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
"+https://www.googleapis.com/auth/userinfo.profile"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.google_photos.async_setup_entry", return_value=True
) as mock_setup:
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
config_entry = result["result"]
assert config_entry.unique_id == USER_IDENTIFIER
assert config_entry.title == "Test Name"
config_entry_data = dict(config_entry.data)
assert "token" in config_entry_data
assert "expires_at" in config_entry_data["token"]
del config_entry_data["token"]["expires_at"]
assert config_entry_data == {
"auth_implementation": DOMAIN,
"token": {
"access_token": "mock-access-token",
"expires_in": 60,
"refresh_token": "mock-refresh-token",
"type": "Bearer",
},
}
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
@pytest.mark.usefixtures(
"current_request_with_host",
"setup_credentials",
)
async def test_api_not_enabled(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
setup_api: Mock,
) -> None:
"""Check flow aborts if api is not enabled."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
"+https://www.googleapis.com/auth/userinfo.profile"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
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."
)
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
async def test_general_exception(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Check flow aborts if exception happens."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
"+https://www.googleapis.com/auth/userinfo.profile"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.google_photos.api.build",
side_effect=Exception,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unknown"

View file

@ -0,0 +1,109 @@
"""Tests for Google Photos."""
import http
import time
from aiohttp import ClientError
import pytest
from homeassistant.components.google_photos.const import OAUTH2_TOKEN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
@pytest.mark.usefixtures("setup_integration")
async def test_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test successful setup and unload."""
assert config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.fixture(name="refresh_token_status")
def mock_refresh_token_status() -> http.HTTPStatus:
"""Fixture to set a token refresh status."""
return http.HTTPStatus.OK
@pytest.fixture(name="refresh_token_exception")
def mock_refresh_token_exception() -> Exception | None:
"""Fixture to set a token refresh status."""
return None
@pytest.fixture(name="refresh_token")
def mock_refresh_token(
aioclient_mock: AiohttpClientMocker,
refresh_token_status: http.HTTPStatus,
refresh_token_exception: Exception | None,
) -> MockConfigEntry:
"""Fixture to simulate a token refresh response."""
aioclient_mock.clear_requests()
aioclient_mock.post(
OAUTH2_TOKEN,
exc=refresh_token_exception,
status=refresh_token_status,
json={
"access_token": "updated-access-token",
"refresh_token": "updated-refresh-token",
"expires_at": time.time() + 3600,
"expires_in": 3600,
},
)
@pytest.mark.usefixtures("refresh_token", "setup_integration")
@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"])
async def test_expired_token_refresh_success(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test expired token is refreshed."""
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.data["token"]["access_token"] == "updated-access-token"
assert config_entry.data["token"]["expires_in"] == 3600
@pytest.mark.usefixtures("refresh_token", "setup_integration")
@pytest.mark.parametrize(
("expires_at", "refresh_token_status", "refresh_token_exception", "expected_state"),
[
(
time.time() - 3600,
http.HTTPStatus.NOT_FOUND,
None,
ConfigEntryState.SETUP_RETRY,
),
(
time.time() - 3600,
http.HTTPStatus.INTERNAL_SERVER_ERROR,
None,
ConfigEntryState.SETUP_RETRY,
),
(
time.time() - 3600,
None,
ClientError("Client exception raised"),
ConfigEntryState.SETUP_RETRY,
),
],
ids=["unauthorized", "internal_server_error", "client_error"],
)
async def test_expired_token_refresh_failure(
hass: HomeAssistant,
config_entry: MockConfigEntry,
expected_state: ConfigEntryState,
) -> None:
"""Test failure while refreshing token with a transient error."""
assert config_entry.state is expected_state

View file

@ -0,0 +1,199 @@
"""Test the Google Photos media source."""
from typing import Any
from unittest.mock import Mock
from googleapiclient.errors import HttpError
from httplib2 import Response
import pytest
from homeassistant.components.google_photos.const import DOMAIN
from homeassistant.components.media_source import (
URI_SCHEME,
BrowseError,
async_browse_media,
async_resolve_media,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
async def setup_components(hass: HomeAssistant) -> None:
"""Fixture to initialize the integration."""
await async_setup_component(hass, "media_source", {})
@pytest.mark.usefixtures("setup_integration")
async def test_no_config_entries(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test a media source with no active config entry."""
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}")
assert browse.domain == DOMAIN
assert browse.identifier is None
assert browse.title == "Google Photos"
assert browse.can_expand
assert not browse.children
@pytest.mark.usefixtures("setup_integration", "setup_api")
@pytest.mark.parametrize(
("fixture_name", "expected_results", "expected_medias"),
[
("list_mediaitems_empty.json", [], []),
(
"list_mediaitems.json",
[
("config-entry-id-123/p/id1", "example1.jpg"),
("config-entry-id-123/p/id2", "example2.mp4"),
],
[
("http://img.example.com/id1=w2048", "image/jpeg"),
("http://img.example.com/id2=dv", "video/mp4"),
],
),
],
)
async def test_recent_items(
hass: HomeAssistant,
expected_results: list[tuple[str, str]],
expected_medias: list[tuple[str, str]],
) -> None:
"""Test a media source with no eligible camera devices."""
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}")
assert browse.domain == DOMAIN
assert browse.identifier is None
assert browse.title == "Google Photos"
assert [(child.identifier, child.title) for child in browse.children] == [
("config-entry-id-123", "Account Name")
]
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123")
assert browse.domain == DOMAIN
assert browse.identifier == "config-entry-id-123"
assert browse.title == "Account Name"
assert [(child.identifier, child.title) for child in browse.children] == [
("config-entry-id-123/a/recent", "Recent Photos")
]
browse = await async_browse_media(
hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent"
)
assert browse.domain == DOMAIN
assert browse.identifier == "config-entry-id-123"
assert browse.title == "Account Name"
assert [
(child.identifier, child.title) for child in browse.children
] == expected_results
media = [
await async_resolve_media(
hass, f"{URI_SCHEME}{DOMAIN}/{child.identifier}", None
)
for child in browse.children
]
assert [
(play_media.url, play_media.mime_type) for play_media in media
] == expected_medias
@pytest.mark.usefixtures("setup_integration", "setup_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.parametrize("fixture_name", ["list_mediaitems.json"])
async def test_invalid_album_id(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
assert browse.identifier is None
assert browse.title == "Google Photos"
assert [(child.identifier, child.title) for child in browse.children] == [
("config-entry-id-123", "Account Name")
]
with pytest.raises(BrowseError, match="Unsupported album"):
await async_browse_media(
hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/invalid-album-id"
)
@pytest.mark.usefixtures("setup_integration")
@pytest.mark.parametrize(
("identifier", "expected_error"),
[
("invalid-config-entry", "without a photo_media_id"),
("too/many/slashes/in/path", "Invalid identifier"),
],
)
async def test_missing_photo_id(
hass: HomeAssistant, identifier: str, expected_error: str
) -> None:
"""Test parsing an invalid media identifier."""
with pytest.raises(BrowseError, match=expected_error):
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:
"""Test browsing to an album id that does not exist."""
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}")
assert browse.domain == DOMAIN
assert browse.identifier is None
assert browse.title == "Google Photos"
assert [(child.identifier, child.title) for child in browse.children] == [
("config-entry-id-123", "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-123/a/recent"
)
@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:
"""Test browsing to an album id that does not exist."""
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}")
assert browse.domain == DOMAIN
assert browse.identifier is None
assert browse.title == "Google Photos"
assert [(child.identifier, child.title) for child in browse.children] == [
("config-entry-id-123", "Account Name")
]
with pytest.raises(BrowseError, match="Error listing media items"):
await async_browse_media(
hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent"
)