Skip to content

Commit

Permalink
Add Google Photos integration (#124835)
Browse files Browse the repository at this point in the history
* 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
allenporter and joostlek authored Aug 30, 2024
1 parent 5e93394 commit c01bb44
Show file tree
Hide file tree
Showing 27 changed files with 1,321 additions and 0 deletions.
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions homeassistant/brands/google.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"google_generative_ai_conversation",
"google_mail",
"google_maps",
"google_photos",
"google_pubsub",
"google_sheets",
"google_tasks",
Expand Down
45 changes: 45 additions & 0 deletions homeassistant/components/google_photos/__init__.py
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
143 changes: 143 additions & 0 deletions homeassistant/components/google_photos/api.py
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 homeassistant/components/google_photos/application_credentials.py
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",
}
54 changes: 54 additions & 0 deletions homeassistant/components/google_photos/config_flow.py
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)
10 changes: 10 additions & 0 deletions homeassistant/components/google_photos/const.py
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",
]
7 changes: 7 additions & 0 deletions homeassistant/components/google_photos/exceptions.py
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."""
10 changes: 10 additions & 0 deletions homeassistant/components/google_photos/manifest.json
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"]
}
Loading

0 comments on commit c01bb44

Please sign in to comment.