mirror of
https://github.com/home-assistant/core.git
synced 2025-07-27 07:07:28 +00:00
Add Google Photos service for uploading content (#124956)
* Add Google Photos upload support * Fix format * Merge in scope/reauth changes * Address PR feedback * Fix blocking i/o in async
This commit is contained in:
parent
d3879a36d1
commit
ef84a8869e
@ -11,6 +11,7 @@ from homeassistant.helpers import config_entry_oauth2_flow
|
|||||||
|
|
||||||
from . import api
|
from . import api
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .services import async_register_services
|
||||||
|
|
||||||
type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth]
|
type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth]
|
||||||
|
|
||||||
@ -41,6 +42,9 @@ async def async_setup_entry(
|
|||||||
except ClientError as err:
|
except ClientError as err:
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady from err
|
||||||
entry.runtime_data = auth
|
entry.runtime_data = auth
|
||||||
|
|
||||||
|
async_register_services(hass)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ from functools import partial
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from aiohttp.client_exceptions import ClientError
|
||||||
from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
from googleapiclient.discovery import Resource, build
|
from googleapiclient.discovery import Resource, build
|
||||||
from googleapiclient.errors import HttpError
|
from googleapiclient.errors import HttpError
|
||||||
@ -12,7 +13,7 @@ from googleapiclient.http import BatchHttpRequest, HttpRequest
|
|||||||
|
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||||
|
|
||||||
from .exceptions import GooglePhotosApiError
|
from .exceptions import GooglePhotosApiError
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ GET_MEDIA_ITEM_FIELDS = (
|
|||||||
"id,baseUrl,mimeType,filename,mediaMetadata(width,height,photo,video)"
|
"id,baseUrl,mimeType,filename,mediaMetadata(width,height,photo,video)"
|
||||||
)
|
)
|
||||||
LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})"
|
LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})"
|
||||||
|
UPLOAD_API = "https://photoslibrary.googleapis.com/v1/uploads"
|
||||||
|
|
||||||
|
|
||||||
class AuthBase(ABC):
|
class AuthBase(ABC):
|
||||||
@ -70,6 +72,40 @@ class AuthBase(ABC):
|
|||||||
)
|
)
|
||||||
return await self._execute(cmd)
|
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:
|
async def _get_photos_service(self) -> Resource:
|
||||||
"""Get current photos library API resource."""
|
"""Get current photos library API resource."""
|
||||||
token = await self.async_get_access_token()
|
token = await self.async_get_access_token()
|
||||||
@ -141,3 +177,13 @@ class AsyncConfigFlowAuth(AuthBase):
|
|||||||
async def async_get_access_token(self) -> str:
|
async def async_get_access_token(self) -> str:
|
||||||
"""Return a valid access token."""
|
"""Return a valid access token."""
|
||||||
return self._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,7 +4,14 @@ DOMAIN = "google_photos"
|
|||||||
|
|
||||||
OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth"
|
OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
OAUTH2_TOKEN = "https://oauth2.googleapis.com/token"
|
OAUTH2_TOKEN = "https://oauth2.googleapis.com/token"
|
||||||
OAUTH2_SCOPES = [
|
|
||||||
|
UPLOAD_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly"
|
||||||
|
READ_SCOPES = [
|
||||||
"https://www.googleapis.com/auth/photoslibrary.readonly",
|
"https://www.googleapis.com/auth/photoslibrary.readonly",
|
||||||
|
"https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
|
||||||
|
]
|
||||||
|
OAUTH2_SCOPES = [
|
||||||
|
*READ_SCOPES,
|
||||||
|
UPLOAD_SCOPE,
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
]
|
]
|
||||||
|
7
homeassistant/components/google_photos/icons.json
Normal file
7
homeassistant/components/google_photos/icons.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"upload": {
|
||||||
|
"service": "mdi:cloud-upload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -16,7 +16,7 @@ from homeassistant.components.media_source import (
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from . import GooglePhotosConfigEntry
|
from . import GooglePhotosConfigEntry
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN, READ_SCOPES
|
||||||
from .exceptions import GooglePhotosApiError
|
from .exceptions import GooglePhotosApiError
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -168,7 +168,7 @@ class GooglePhotosMediaSource(MediaSource):
|
|||||||
children_media_class=MediaClass.DIRECTORY,
|
children_media_class=MediaClass.DIRECTORY,
|
||||||
children=[
|
children=[
|
||||||
_build_account(entry, PhotosIdentifier(cast(str, entry.unique_id)))
|
_build_account(entry, PhotosIdentifier(cast(str, entry.unique_id)))
|
||||||
for entry in self.hass.config_entries.async_loaded_entries(DOMAIN)
|
for entry in self._async_config_entries()
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -218,6 +218,15 @@ class GooglePhotosMediaSource(MediaSource):
|
|||||||
]
|
]
|
||||||
return source
|
return source
|
||||||
|
|
||||||
|
def _async_config_entries(self) -> list[GooglePhotosConfigEntry]:
|
||||||
|
"""Return all config entries that support photo library reads."""
|
||||||
|
entries = []
|
||||||
|
for entry in self.hass.config_entries.async_loaded_entries(DOMAIN):
|
||||||
|
scopes = entry.data["token"]["scope"].split(" ")
|
||||||
|
if any(scope in scopes for scope in READ_SCOPES):
|
||||||
|
entries.append(entry)
|
||||||
|
return entries
|
||||||
|
|
||||||
def _async_config_entry(self, config_entry_id: str) -> GooglePhotosConfigEntry:
|
def _async_config_entry(self, config_entry_id: str) -> GooglePhotosConfigEntry:
|
||||||
"""Return a config entry with the specified id."""
|
"""Return a config entry with the specified id."""
|
||||||
entry = self.hass.config_entries.async_entry_for_domain_unique_id(
|
entry = self.hass.config_entries.async_entry_for_domain_unique_id(
|
||||||
|
116
homeassistant/components/google_photos/services.py
Normal file
116
homeassistant/components/google_photos/services.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"""Google Photos services."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_FILENAME
|
||||||
|
from homeassistant.core import (
|
||||||
|
HomeAssistant,
|
||||||
|
ServiceCall,
|
||||||
|
ServiceResponse,
|
||||||
|
SupportsResponse,
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONF_CONFIG_ENTRY_ID = "config_entry_id"
|
||||||
|
|
||||||
|
UPLOAD_SERVICE = "upload"
|
||||||
|
UPLOAD_SERVICE_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||||
|
vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_file_contents(
|
||||||
|
hass: HomeAssistant, filenames: list[str]
|
||||||
|
) -> list[tuple[str, bytes]]:
|
||||||
|
"""Read the mime type and contents from each filen."""
|
||||||
|
results = []
|
||||||
|
for filename in filenames:
|
||||||
|
if not hass.config.is_allowed_path(filename):
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="no_access_to_path",
|
||||||
|
translation_placeholders={"filename": filename},
|
||||||
|
)
|
||||||
|
filename_path = Path(filename)
|
||||||
|
if not filename_path.exists():
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="filename_does_not_exist",
|
||||||
|
translation_placeholders={"filename": filename},
|
||||||
|
)
|
||||||
|
mime_type, _ = mimetypes.guess_type(filename)
|
||||||
|
if mime_type is None or not (mime_type.startswith(("image", "video"))):
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="filename_is_not_image",
|
||||||
|
translation_placeholders={"filename": filename},
|
||||||
|
)
|
||||||
|
results.append((mime_type, filename_path.read_bytes()))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def async_register_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Register Google Photos services."""
|
||||||
|
|
||||||
|
async def async_handle_upload(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Generate content from text and optionally images."""
|
||||||
|
config_entry: GooglePhotosConfigEntry | None = (
|
||||||
|
hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID])
|
||||||
|
)
|
||||||
|
if not config_entry:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="integration_not_found",
|
||||||
|
translation_placeholders={"target": DOMAIN},
|
||||||
|
)
|
||||||
|
scopes = config_entry.data["token"]["scope"].split(" ")
|
||||||
|
if UPLOAD_SCOPE not in scopes:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="missing_upload_permission",
|
||||||
|
translation_placeholders={"target": DOMAIN},
|
||||||
|
)
|
||||||
|
|
||||||
|
client_api = config_entry.runtime_data
|
||||||
|
upload_tasks = []
|
||||||
|
file_results = await hass.async_add_executor_job(
|
||||||
|
_read_file_contents, hass, call.data[CONF_FILENAME]
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
if call.return_response:
|
||||||
|
return {
|
||||||
|
"media_items": [{"media_item_id": media_id for media_id in media_ids}]
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE):
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
UPLOAD_SERVICE,
|
||||||
|
async_handle_upload,
|
||||||
|
schema=UPLOAD_SERVICE_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
|
)
|
11
homeassistant/components/google_photos/services.yaml
Normal file
11
homeassistant/components/google_photos/services.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
upload:
|
||||||
|
fields:
|
||||||
|
config_entry_id:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
config_entry:
|
||||||
|
integration: google_photos
|
||||||
|
filename:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
@ -26,5 +26,42 @@
|
|||||||
"create_entry": {
|
"create_entry": {
|
||||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"integration_not_found": {
|
||||||
|
"message": "Integration \"{target}\" not found in registry."
|
||||||
|
},
|
||||||
|
"not_loaded": {
|
||||||
|
"message": "{target} is not loaded."
|
||||||
|
},
|
||||||
|
"no_access_to_path": {
|
||||||
|
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||||
|
},
|
||||||
|
"filename_does_not_exist": {
|
||||||
|
"message": "`{filename}` does not exist"
|
||||||
|
},
|
||||||
|
"filename_is_not_image": {
|
||||||
|
"message": "`{filename}` is not an image"
|
||||||
|
},
|
||||||
|
"missing_upload_permission": {
|
||||||
|
"message": "Home Assistnt was not granted permission to upload to Google Photos"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"upload": {
|
||||||
|
"name": "Upload media",
|
||||||
|
"description": "Upload images or videos to Google Photos.",
|
||||||
|
"fields": {
|
||||||
|
"config_entry_id": {
|
||||||
|
"name": "Integration Id",
|
||||||
|
"description": "The Google Photos integration id."
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"name": "Filename",
|
||||||
|
"description": "Path to the image or video to upload.",
|
||||||
|
"example": "/config/www/image.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -32,13 +32,19 @@ def mock_expires_at() -> int:
|
|||||||
return time.time() + EXPIRES_IN
|
return time.time() + EXPIRES_IN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="scopes")
|
||||||
|
def mock_scopes() -> list[str]:
|
||||||
|
"""Fixture to set scopes used during the config entry."""
|
||||||
|
return OAUTH2_SCOPES
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="token_entry")
|
@pytest.fixture(name="token_entry")
|
||||||
def mock_token_entry(expires_at: int) -> dict[str, Any]:
|
def mock_token_entry(expires_at: int, scopes: list[str]) -> dict[str, Any]:
|
||||||
"""Fixture for OAuth 'token' data for a ConfigEntry."""
|
"""Fixture for OAuth 'token' data for a ConfigEntry."""
|
||||||
return {
|
return {
|
||||||
"access_token": FAKE_ACCESS_TOKEN,
|
"access_token": FAKE_ACCESS_TOKEN,
|
||||||
"refresh_token": FAKE_REFRESH_TOKEN,
|
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||||
"scope": " ".join(OAUTH2_SCOPES),
|
"scope": " ".join(scopes),
|
||||||
"type": "Bearer",
|
"type": "Bearer",
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
"expires_in": EXPIRES_IN,
|
"expires_in": EXPIRES_IN,
|
||||||
|
@ -84,6 +84,8 @@ async def test_full_flow(
|
|||||||
"&redirect_uri=https://example.com/auth/external/callback"
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
f"&state={state}"
|
f"&state={state}"
|
||||||
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
|
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
|
||||||
|
"+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"
|
||||||
|
"+https://www.googleapis.com/auth/photoslibrary.appendonly"
|
||||||
"+https://www.googleapis.com/auth/userinfo.profile"
|
"+https://www.googleapis.com/auth/userinfo.profile"
|
||||||
"&access_type=offline&prompt=consent"
|
"&access_type=offline&prompt=consent"
|
||||||
)
|
)
|
||||||
@ -111,6 +113,8 @@ async def test_full_flow(
|
|||||||
"type": "Bearer",
|
"type": "Bearer",
|
||||||
"scope": (
|
"scope": (
|
||||||
"https://www.googleapis.com/auth/photoslibrary.readonly"
|
"https://www.googleapis.com/auth/photoslibrary.readonly"
|
||||||
|
" https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"
|
||||||
|
" https://www.googleapis.com/auth/photoslibrary.appendonly"
|
||||||
" https://www.googleapis.com/auth/userinfo.profile"
|
" https://www.googleapis.com/auth/userinfo.profile"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -145,6 +149,8 @@ async def test_api_not_enabled(
|
|||||||
"&redirect_uri=https://example.com/auth/external/callback"
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
f"&state={state}"
|
f"&state={state}"
|
||||||
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
|
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
|
||||||
|
"+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"
|
||||||
|
"+https://www.googleapis.com/auth/photoslibrary.appendonly"
|
||||||
"+https://www.googleapis.com/auth/userinfo.profile"
|
"+https://www.googleapis.com/auth/userinfo.profile"
|
||||||
"&access_type=offline&prompt=consent"
|
"&access_type=offline&prompt=consent"
|
||||||
)
|
)
|
||||||
@ -189,6 +195,8 @@ async def test_general_exception(
|
|||||||
"&redirect_uri=https://example.com/auth/external/callback"
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
f"&state={state}"
|
f"&state={state}"
|
||||||
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
|
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
|
||||||
|
"+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"
|
||||||
|
"+https://www.googleapis.com/auth/photoslibrary.appendonly"
|
||||||
"+https://www.googleapis.com/auth/userinfo.profile"
|
"+https://www.googleapis.com/auth/userinfo.profile"
|
||||||
"&access_type=offline&prompt=consent"
|
"&access_type=offline&prompt=consent"
|
||||||
)
|
)
|
||||||
@ -274,6 +282,8 @@ async def test_reauth(
|
|||||||
"&redirect_uri=https://example.com/auth/external/callback"
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
f"&state={state}"
|
f"&state={state}"
|
||||||
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
|
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
|
||||||
|
"+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"
|
||||||
|
"+https://www.googleapis.com/auth/photoslibrary.appendonly"
|
||||||
"+https://www.googleapis.com/auth/userinfo.profile"
|
"+https://www.googleapis.com/auth/userinfo.profile"
|
||||||
"&access_type=offline&prompt=consent"
|
"&access_type=offline&prompt=consent"
|
||||||
)
|
)
|
||||||
@ -305,6 +315,8 @@ async def test_reauth(
|
|||||||
"type": "Bearer",
|
"type": "Bearer",
|
||||||
"scope": (
|
"scope": (
|
||||||
"https://www.googleapis.com/auth/photoslibrary.readonly"
|
"https://www.googleapis.com/auth/photoslibrary.readonly"
|
||||||
|
" https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"
|
||||||
|
" https://www.googleapis.com/auth/photoslibrary.appendonly"
|
||||||
" https://www.googleapis.com/auth/userinfo.profile"
|
" https://www.googleapis.com/auth/userinfo.profile"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
@ -7,7 +7,7 @@ from googleapiclient.errors import HttpError
|
|||||||
from httplib2 import Response
|
from httplib2 import Response
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.google_photos.const import DOMAIN
|
from homeassistant.components.google_photos.const import DOMAIN, UPLOAD_SCOPE
|
||||||
from homeassistant.components.media_source import (
|
from homeassistant.components.media_source import (
|
||||||
URI_SCHEME,
|
URI_SCHEME,
|
||||||
BrowseError,
|
BrowseError,
|
||||||
@ -46,6 +46,24 @@ async def test_no_config_entries(
|
|||||||
assert not browse.children
|
assert not browse.children
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("setup_integration", "setup_api")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("scopes"),
|
||||||
|
[
|
||||||
|
[UPLOAD_SCOPE],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_no_read_scopes(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> None:
|
||||||
|
"""Test a media source with only write scopes configured so no media source exists."""
|
||||||
|
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 not browse.children
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("setup_integration", "setup_api")
|
@pytest.mark.usefixtures("setup_integration", "setup_api")
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("fixture_name", "expected_results", "expected_medias"),
|
("fixture_name", "expected_results", "expected_medias"),
|
||||||
|
256
tests/components/google_photos/test_services.py
Normal file
256
tests/components/google_photos/test_services.py
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
"""Tests for Google Photos."""
|
||||||
|
|
||||||
|
import http
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
|
from httplib2 import Response
|
||||||
|
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,
|
||||||
|
) -> 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.google_photos.services.Path.read_bytes",
|
||||||
|
return_value=b"image bytes",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.google_photos.services.Path.exists",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
patch.object(hass.config, "is_allowed_path", return_value=True),
|
||||||
|
):
|
||||||
|
response = await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"upload",
|
||||||
|
{
|
||||||
|
"config_entry_id": config_entry.entry_id,
|
||||||
|
"filename": "doorbell_snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
assert response == {"media_items": [{"media_item_id": "new-media-item-id-1"}]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("setup_integration")
|
||||||
|
async def test_upload_service_config_entry_not_found(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test upload service call with a config entry that does not exist."""
|
||||||
|
with pytest.raises(HomeAssistantError, match="not found in registry"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"upload",
|
||||||
|
{
|
||||||
|
"config_entry_id": "invalid-config-entry-id",
|
||||||
|
"filename": "doorbell_snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("setup_integration")
|
||||||
|
async def test_config_entry_not_loaded(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test upload service call with a config entry that is not 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
|
||||||
|
|
||||||
|
with pytest.raises(HomeAssistantError, match="not found in registry"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"upload",
|
||||||
|
{
|
||||||
|
"config_entry_id": config_entry.unique_id,
|
||||||
|
"filename": "doorbell_snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("setup_integration")
|
||||||
|
async def test_path_is_not_allowed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test upload service call with a filename path that is not allowed."""
|
||||||
|
with (
|
||||||
|
patch.object(hass.config, "is_allowed_path", return_value=False),
|
||||||
|
pytest.raises(HomeAssistantError, match="no access to path"),
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"upload",
|
||||||
|
{
|
||||||
|
"config_entry_id": config_entry.entry_id,
|
||||||
|
"filename": "doorbell_snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("setup_integration")
|
||||||
|
async def test_filename_does_not_exist(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test upload service call with a filename path that does not exist."""
|
||||||
|
with (
|
||||||
|
patch.object(hass.config, "is_allowed_path", return_value=True),
|
||||||
|
patch("pathlib.Path.exists", return_value=False),
|
||||||
|
pytest.raises(HomeAssistantError, match="does not exist"),
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"upload",
|
||||||
|
{
|
||||||
|
"config_entry_id": config_entry.entry_id,
|
||||||
|
"filename": "doorbell_snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("setup_integration")
|
||||||
|
async def test_upload_service_upload_content_failure(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
setup_api: Mock,
|
||||||
|
) -> None:
|
||||||
|
"""Test service call to upload content."""
|
||||||
|
|
||||||
|
aioclient_mock.post(UPLOAD_API, status=http.HTTPStatus.SERVICE_UNAVAILABLE)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.google_photos.services.Path.read_bytes",
|
||||||
|
return_value=b"image bytes",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.google_photos.services.Path.exists",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
patch.object(hass.config, "is_allowed_path", return_value=True),
|
||||||
|
pytest.raises(HomeAssistantError, match="Failed to upload content"),
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"upload",
|
||||||
|
{
|
||||||
|
"config_entry_id": config_entry.entry_id,
|
||||||
|
"filename": "doorbell_snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("setup_integration")
|
||||||
|
async def test_upload_service_fails_create(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
setup_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""
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.google_photos.services.Path.read_bytes",
|
||||||
|
return_value=b"image bytes",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.google_photos.services.Path.exists",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
patch.object(hass.config, "is_allowed_path", return_value=True),
|
||||||
|
pytest.raises(
|
||||||
|
HomeAssistantError, match="Google Photos API responded with error"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"upload",
|
||||||
|
{
|
||||||
|
"config_entry_id": config_entry.entry_id,
|
||||||
|
"filename": "doorbell_snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("setup_integration")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("scopes"),
|
||||||
|
[
|
||||||
|
READ_SCOPES,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
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."""
|
||||||
|
|
||||||
|
with pytest.raises(HomeAssistantError, match="not granted permission"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"upload",
|
||||||
|
{
|
||||||
|
"config_entry_id": config_entry.entry_id,
|
||||||
|
"filename": "doorbell_snapshot.jpg",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user