-
-
Notifications
You must be signed in to change notification settings - Fork 32.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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>
- Loading branch information
1 parent
5e93394
commit c01bb44
Showing
27 changed files
with
1,321 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
23 changes: 23 additions & 0 deletions
23
homeassistant/components/google_photos/application_credentials.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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", | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"] | ||
} |
Oops, something went wrong.