Update google calendar integration with a config flow (#68010)

* Convert google calendar to config flow and async

* Call correct exchange method

* Fix async method and reduce unnecessary diffs

* Wording improvements

* Reduce unnecessary diffs

* Run load/update config from executor

* Update homeassistant/components/google/calendar.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remove unnecessary updating of unexpected multiple config entries.

* Remove unnecessary unique_id checks

* Improve readability with comments about device code expiration

* Update homeassistant/components/google/calendar.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/google/calendar.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/google/api.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Add comment for when code is none on timeout

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2022-03-14 23:51:02 -07:00 committed by GitHub
parent 4988c4683c
commit 7876ffe9e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 989 additions and 443 deletions

View File

@ -1,23 +1,20 @@
"""Support for Google - Calendar Event Devices."""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from enum import Enum
from datetime import datetime, timedelta
import logging
from typing import Any
from oauth2client.client import (
FlowExchangeError,
OAuth2DeviceCodeError,
OAuth2WebServerFlow,
)
from httplib2.error import ServerNotFoundError
from oauth2client.file import Storage
import voluptuous as vol
from voluptuous.error import Error as VoluptuousError
import yaml
from homeassistant.components import persistent_notification
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
@ -25,20 +22,28 @@ from homeassistant.const import (
CONF_ENTITIES,
CONF_NAME,
CONF_OFFSET,
Platform,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall
from homeassistant.helpers import discovery
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers import config_entry_oauth2_flow
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.event import track_utc_time_change
from homeassistant.helpers.typing import ConfigType
from .api import GoogleCalendarService
from . import config_flow
from .api import DeviceAuth, GoogleCalendarService
from .const import (
CONF_CALENDAR_ACCESS,
DATA_CONFIG,
DATA_SERVICE,
DISCOVER_CALENDAR,
DOMAIN,
FeatureAccess,
)
_LOGGER = logging.getLogger(__name__)
DOMAIN = "google"
ENTITY_ID_FORMAT = DOMAIN + ".{}"
CONF_TRACK_NEW = "track_new_calendar"
@ -48,7 +53,6 @@ CONF_TRACK = "track"
CONF_SEARCH = "search"
CONF_IGNORE_AVAILABILITY = "ignore_availability"
CONF_MAX_RESULTS = "max_results"
CONF_CALENDAR_ACCESS = "calendar_access"
DEFAULT_CONF_OFFSET = "!!"
@ -74,27 +78,11 @@ SERVICE_SCAN_CALENDARS = "scan_for_calendars"
SERVICE_FOUND_CALENDARS = "found_calendar"
SERVICE_ADD_EVENT = "add_event"
DATA_SERVICE = "service"
YAML_DEVICES = f"{DOMAIN}_calendars.yaml"
TOKEN_FILE = f".{DOMAIN}.token"
class FeatureAccess(Enum):
"""Class to represent different access scopes."""
read_only = "https://www.googleapis.com/auth/calendar.readonly"
read_write = "https://www.googleapis.com/auth/calendar"
def __init__(self, scope: str) -> None:
"""Init instance."""
self._scope = scope
@property
def scope(self) -> str:
"""Google calendar scope for the feature."""
return self._scope
PLATFORMS = ["calendar"]
CONFIG_SCHEMA = vol.Schema(
@ -159,131 +147,79 @@ ADD_EVENT_SERVICE_SCHEMA = vol.Schema(
)
def do_authentication(
hass: HomeAssistant,
hass_config: ConfigType,
config: ConfigType,
storage: Storage,
) -> bool:
"""Notify user of actions and authenticate.
Notify user of user_code and verification_url then poll
until we have an access token.
"""
oauth = OAuth2WebServerFlow(
client_id=config[CONF_CLIENT_ID],
client_secret=config[CONF_CLIENT_SECRET],
scope=config[CONF_CALENDAR_ACCESS].scope,
redirect_uri="Home-Assistant.io",
)
try:
dev_flow = oauth.step1_get_device_and_user_codes()
except OAuth2DeviceCodeError as err:
persistent_notification.create(
hass,
f"Error: {err}<br />You will need to restart hass after fixing." "",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
return False
persistent_notification.create(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Google component."""
conf = config.get(DOMAIN, {})
hass.data[DOMAIN] = {DATA_CONFIG: conf}
config_flow.OAuth2FlowHandler.async_register_implementation(
hass,
(
f"In order to authorize Home-Assistant to view your calendars "
f'you must visit: <a href="{dev_flow.verification_url}" target="_blank">{dev_flow.verification_url}</a> and enter '
f"code: {dev_flow.user_code}"
),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
listener: CALLBACK_TYPE | None = None
def step2_exchange(now: datetime) -> None:
"""Keep trying to validate the user_code until it expires."""
_LOGGER.debug("Attempting to validate user code")
# For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
# object without tzinfo. For the comparison below to work, it needs one.
user_code_expiry = dev_flow.user_code_expiry.replace(tzinfo=timezone.utc)
if now >= user_code_expiry:
persistent_notification.create(
hass,
"Authentication code expired, please restart "
"Home-Assistant and try again",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
assert listener
listener()
return
try:
credentials = oauth.step2_exchange(device_flow_info=dev_flow)
except FlowExchangeError:
# not ready yet, call again
return
storage.put(credentials)
do_setup(hass, hass_config, config)
assert listener
listener()
persistent_notification.create(
DeviceAuth(
hass,
(
f"We are all setup now. Check {YAML_DEVICES} for calendars that have "
f"been found"
),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
listener = track_utc_time_change(
hass, step2_exchange, second=range(1, 60, dev_flow.interval)
conf[CONF_CLIENT_ID],
conf[CONF_CLIENT_SECRET],
),
)
return True
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Google platform."""
if not (conf := config.get(DOMAIN, {})):
# component is set up by tts platform
return True
# Import credentials from the old token file into the new way as
# a ConfigEntry managed by home assistant.
storage = Storage(hass.config.path(TOKEN_FILE))
hass.data[DOMAIN] = {
DATA_SERVICE: GoogleCalendarService(hass, storage),
}
creds = storage.get()
if (
not creds
or not creds.scopes
or conf[CONF_CALENDAR_ACCESS].scope not in creds.scopes
):
do_authentication(hass, config, conf, storage)
else:
do_setup(hass, config, conf)
creds = await hass.async_add_executor_job(storage.get)
if creds and conf[CONF_CALENDAR_ACCESS].scope in creds.scopes:
_LOGGER.debug("Importing configuration entry with credentials")
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"creds": creds,
},
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
assert isinstance(implementation, DeviceAuth)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
required_scope = hass.data[DOMAIN][DATA_CONFIG][CONF_CALENDAR_ACCESS].scope
if required_scope not in session.token.get("scope", []):
raise ConfigEntryAuthFailed(
"Required scopes are not available, reauth required"
)
calendar_service = GoogleCalendarService(hass, session)
hass.data[DOMAIN][DATA_SERVICE] = calendar_service
await async_setup_services(hass, hass.data[DOMAIN][DATA_CONFIG], calendar_service)
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
def setup_services(
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_setup_services(
hass: HomeAssistant,
hass_config: ConfigType,
config: ConfigType,
calendar_service: GoogleCalendarService,
) -> None:
"""Set up the service listeners."""
created_calendars = set()
calendars = load_config(hass.config.path(YAML_DEVICES))
calendars = await hass.async_add_executor_job(
load_config, hass.config.path(YAML_DEVICES)
)
def _found_calendar(call: ServiceCall) -> None:
"""Check if we know about a calendar and generate PLATFORM_DISCOVER."""
async def _found_calendar(call: ServiceCall) -> None:
calendar = get_calendar_info(hass, call.data)
calendar_id = calendar[CONF_CAL_ID]
@ -294,31 +230,33 @@ def setup_services(
# Populate the yaml file with all discovered calendars
if calendar_id not in calendars:
calendars[calendar_id] = calendar
update_config(hass.config.path(YAML_DEVICES), calendar)
await hass.async_add_executor_job(
update_config, hass.config.path(YAML_DEVICES), calendar
)
else:
# Prefer entity/name information from yaml, overriding api
calendar = calendars[calendar_id]
async_dispatcher_send(hass, DISCOVER_CALENDAR, calendar)
discovery.load_platform(
hass,
Platform.CALENDAR,
DOMAIN,
calendar,
hass_config,
)
hass.services.async_register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
hass.services.register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
def _scan_for_calendars(call: ServiceCall) -> None:
async def _scan_for_calendars(call: ServiceCall) -> None:
"""Scan for new calendars."""
calendars = calendar_service.list_calendars()
try:
calendars = await calendar_service.async_list_calendars()
except ServerNotFoundError as err:
raise HomeAssistantError(str(err)) from err
tasks = []
for calendar in calendars:
calendar[CONF_TRACK] = config[CONF_TRACK_NEW]
hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar)
tasks.append(
hass.services.async_call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar)
)
await asyncio.gather(*tasks)
hass.services.register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars)
hass.services.async_register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars)
def _add_event(call: ServiceCall) -> None:
async def _add_event(call: ServiceCall) -> None:
"""Add a new event to calendar."""
start = {}
end = {}
@ -354,7 +292,7 @@ def setup_services(
start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)}
end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)}
calendar_service.create_event(
await calendar_service.async_create_event(
call.data[EVENT_CALENDAR_ID],
{
"summary": call.data[EVENT_SUMMARY],
@ -366,20 +304,11 @@ def setup_services(
# Only expose the add event service if we have the correct permissions
if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write:
hass.services.register(
hass.services.async_register(
DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA
)
def do_setup(hass: HomeAssistant, hass_config: ConfigType, config: ConfigType) -> None:
"""Run the setup after we have everything configured."""
calendar_service = hass.data[DOMAIN][DATA_SERVICE]
setup_services(hass, hass_config, config, calendar_service)
# Fetch calendars from the API
hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None)
def get_calendar_info(
hass: HomeAssistant, calendar: Mapping[str, Any]
) -> dict[str, Any]:

View File

@ -2,20 +2,166 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
import datetime
import logging
from typing import Any
from googleapiclient import discovery as google_discovery
from oauth2client.file import Storage
import oauth2client
from oauth2client.client import (
Credentials,
DeviceFlowInfo,
FlowExchangeError,
OAuth2Credentials,
OAuth2DeviceCodeError,
OAuth2WebServerFlow,
)
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util import dt
from .const import CONF_CALENDAR_ACCESS, DATA_CONFIG, DEVICE_AUTH_IMPL, DOMAIN
_LOGGER = logging.getLogger(__name__)
EVENT_PAGE_SIZE = 100
EXCHANGE_TIMEOUT_SECONDS = 60
class OAuthError(Exception):
"""OAuth related error."""
class DeviceAuth(config_entry_oauth2_flow.LocalOAuth2Implementation):
"""OAuth implementation for Device Auth."""
def __init__(self, hass: HomeAssistant, client_id: str, client_secret: str) -> None:
"""Initialize InstalledAppAuth."""
super().__init__(
hass,
DEVICE_AUTH_IMPL,
client_id,
client_secret,
oauth2client.GOOGLE_AUTH_URI,
oauth2client.GOOGLE_TOKEN_URI,
)
async def async_resolve_external_data(self, external_data: Any) -> dict:
"""Resolve a Google API Credentials object to Home Assistant token."""
creds: Credentials = external_data["creds"]
return {
"access_token": creds.access_token,
"refresh_token": creds.refresh_token,
"scope": " ".join(creds.scopes),
"token_type": "Bearer",
"expires_in": creds.token_expiry.timestamp(),
}
class DeviceFlow:
"""OAuth2 device flow for exchanging a code for an access token."""
def __init__(
self,
hass: HomeAssistant,
oauth_flow: OAuth2WebServerFlow,
device_flow_info: DeviceFlowInfo,
) -> None:
"""Initialize DeviceFlow."""
self._hass = hass
self._oauth_flow = oauth_flow
self._device_flow_info: DeviceFlowInfo = device_flow_info
self._exchange_task_unsub: CALLBACK_TYPE | None = None
@property
def verification_url(self) -> str:
"""Return the verification url that the user should visit to enter the code."""
return self._device_flow_info.verification_url
@property
def user_code(self) -> str:
"""Return the code that the user should enter at the verification url."""
return self._device_flow_info.user_code
async def start_exchange_task(
self, finished_cb: Callable[[Credentials | None], Awaitable[None]]
) -> None:
"""Start the device auth exchange flow polling.
The callback is invoked with the valid credentials or with None on timeout.
"""
_LOGGER.debug("Starting exchange flow")
assert not self._exchange_task_unsub
max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS)
# For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
# object without tzinfo. For the comparison below to work, it needs one.
user_code_expiry = self._device_flow_info.user_code_expiry.replace(
tzinfo=datetime.timezone.utc
)
expiration_time = min(user_code_expiry, max_timeout)
def _exchange() -> Credentials:
return self._oauth_flow.step2_exchange(
device_flow_info=self._device_flow_info
)
async def _poll_attempt(now: datetime.datetime) -> None:
assert self._exchange_task_unsub
_LOGGER.debug("Attempting OAuth code exchange")
# Note: The callback is invoked with None when the device code has expired
creds: Credentials | None = None
if now < expiration_time:
try:
creds = await self._hass.async_add_executor_job(_exchange)
except FlowExchangeError:
_LOGGER.debug("Token not yet ready; trying again later")
return
self._exchange_task_unsub()
self._exchange_task_unsub = None
await finished_cb(creds)
self._exchange_task_unsub = async_track_time_interval(
self._hass,
_poll_attempt,
datetime.timedelta(seconds=self._device_flow_info.interval),
)
async def async_create_device_flow(hass: HomeAssistant) -> DeviceFlow:
"""Create a new Device flow."""
conf = hass.data[DOMAIN][DATA_CONFIG]
oauth_flow = OAuth2WebServerFlow(
client_id=conf[CONF_CLIENT_ID],
client_secret=conf[CONF_CLIENT_SECRET],
scope=conf[CONF_CALENDAR_ACCESS].scope,
redirect_uri="",
)
try:
device_flow_info = await hass.async_add_executor_job(
oauth_flow.step1_get_device_and_user_codes
)
except OAuth2DeviceCodeError as err:
raise OAuthError(str(err)) from err
return DeviceFlow(hass, oauth_flow, device_flow_info)
def _async_google_creds(hass: HomeAssistant, token: dict[str, Any]) -> Credentials:
"""Convert a Home Assistant token to a Google API Credentials object."""
conf = hass.data[DOMAIN][DATA_CONFIG]
return OAuth2Credentials(
access_token=token["access_token"],
client_id=conf[CONF_CLIENT_ID],
client_secret=conf[CONF_CLIENT_SECRET],
refresh_token=token["refresh_token"],
token_expiry=token["expires_at"],
token_uri=oauth2client.GOOGLE_TOKEN_URI,
scopes=[conf[CONF_CALENDAR_ACCESS].scope],
user_agent=None,
)
def _api_time_format(time: datetime.datetime | None) -> str | None:
@ -26,26 +172,44 @@ def _api_time_format(time: datetime.datetime | None) -> str | None:
class GoogleCalendarService:
"""Calendar service interface to Google."""
def __init__(self, hass: HomeAssistant, storage: Storage) -> None:
def __init__(
self, hass: HomeAssistant, session: config_entry_oauth2_flow.OAuth2Session
) -> None:
"""Init the Google Calendar service."""
self._hass = hass
self._storage = storage
self._session = session
def _get_service(self) -> google_discovery.Resource:
"""Get the calendar service from the storage file token."""
async def _async_get_service(self) -> google_discovery.Resource:
"""Get the calendar service with valid credetnails."""
await self._session.async_ensure_token_valid()
creds = _async_google_creds(self._hass, self._session.token)
return google_discovery.build(
"calendar", "v3", credentials=self._storage.get(), cache_discovery=False
"calendar", "v3", credentials=creds, cache_discovery=False
)
def list_calendars(self) -> list[dict[str, Any]]:
async def async_list_calendars(
self,
) -> list[dict[str, Any]]:
"""Return the list of calendars the user has added to their list."""
cal_list = self._get_service().calendarList() # pylint: disable=no-member
return cal_list.list().execute()["items"]
service = await self._async_get_service()
def create_event(self, calendar_id: str, event: dict[str, Any]) -> dict[str, Any]:
"""Create an event."""
events = self._get_service().events() # pylint: disable=no-member
return events.insert(calendarId=calendar_id, body=event).execute()
def _list_calendars() -> list[dict[str, Any]]:
cal_list = service.calendarList() # pylint: disable=no-member
return cal_list.list().execute()["items"]
return await self._hass.async_add_executor_job(_list_calendars)
async def async_create_event(
self, calendar_id: str, event: dict[str, Any]
) -> dict[str, Any]:
"""Return the list of calendars the user has added to their list."""
service = await self._async_get_service()
def _create_event() -> dict[str, Any]:
events = service.events() # pylint: disable=no-member
return events.insert(calendarId=calendar_id, body=event).execute()
return await self._hass.async_add_executor_job(_create_event)
async def async_list_events(
self,
@ -56,33 +220,20 @@ class GoogleCalendarService:
page_token: str | None = None,
) -> tuple[list[dict[str, Any]], str | None]:
"""Return the list of events."""
return await self._hass.async_add_executor_job(
self.list_events,
calendar_id,
start_time,
end_time,
search,
page_token,
)
service = await self._async_get_service()
def list_events(
self,
calendar_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
search: str | None = None,
page_token: str | None = None,
) -> tuple[list[dict[str, Any]], str | None]:
"""Return the list of events."""
events = self._get_service().events() # pylint: disable=no-member
result = events.list(
calendarId=calendar_id,
timeMin=_api_time_format(start_time if start_time else dt.now()),
timeMax=_api_time_format(end_time),
q=search,
maxResults=EVENT_PAGE_SIZE,
pageToken=page_token,
singleEvents=True, # Flattens recurring events
orderBy="startTime",
).execute()
return (result["items"], result.get("nextPageToken"))
def _list_events() -> tuple[list[dict[str, Any]], str | None]:
events = service.events() # pylint: disable=no-member
result = events.list(
calendarId=calendar_id,
timeMin=_api_time_format(start_time if start_time else dt.now()),
timeMax=_api_time_format(end_time),
q=search,
maxResults=EVENT_PAGE_SIZE,
pageToken=page_token,
singleEvents=True, # Flattens recurring events
orderBy="startTime",
).execute()
return (result["items"], result.get("nextPageToken"))
return await self._hass.async_add_executor_job(_list_events)

View File

@ -14,11 +14,13 @@ from homeassistant.components.calendar import (
calculate_offset,
is_offset_reached,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
from . import (
@ -29,8 +31,10 @@ from . import (
DATA_SERVICE,
DEFAULT_CONF_OFFSET,
DOMAIN,
SERVICE_SCAN_CALENDARS,
)
from .api import GoogleCalendarService
from .const import DISCOVER_CALENDAR
_LOGGER = logging.getLogger(__name__)
@ -48,19 +52,39 @@ TRANSPARENCY = "transparency"
OPAQUE = "opaque"
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
disc_info: DiscoveryInfoType | None = None,
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the calendar platform for event devices."""
if disc_info is None:
return
"""Set up the google calendar platform."""
if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]):
return
@callback
def async_discover(discovery_info: dict[str, Any]) -> None:
_async_setup_entities(
hass,
entry,
async_add_entities,
discovery_info,
)
entry.async_on_unload(
async_dispatcher_connect(hass, DISCOVER_CALENDAR, async_discover)
)
# Look for any new calendars
try:
await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, blocking=True)
except HomeAssistantError as err:
# This can happen if there's a connection error during setup.
raise PlatformNotReady(str(err)) from err
@callback
def _async_setup_entities(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
disc_info: dict[str, Any],
) -> None:
calendar_service = hass.data[DOMAIN][DATA_SERVICE]
entities = []
for data in disc_info[CONF_ENTITIES]:
@ -74,7 +98,7 @@ def setup_platform(
)
entities.append(entity)
add_entities(entities, True)
async_add_entities(entities, True)
class GoogleCalendarEventDevice(CalendarEventDevice):
@ -144,10 +168,10 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
return event_list
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self) -> None:
async def async_update(self) -> None:
"""Get the latest data."""
try:
items, _ = self._calendar_service.list_events(
items, _ = await self._calendar_service.async_list_events(
self._calendar_id, search=self._search
)
except ServerNotFoundError as err:

View File

@ -0,0 +1,128 @@
"""Config flow for Google integration."""
from __future__ import annotations
import logging
from typing import Any
from oauth2client.client import Credentials
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .api import DeviceFlow, OAuthError, async_create_device_flow
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Google Calendars OAuth2 authentication."""
DOMAIN = DOMAIN
def __init__(self) -> None:
"""Set up instance."""
super().__init__()
self._reauth = False
self._device_flow: DeviceFlow | None = None
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
async def async_step_import(self, info: dict[str, Any]) -> FlowResult:
"""Import existing auth from Nest."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
implementations = await config_entry_oauth2_flow.async_get_implementations(
self.hass, self.DOMAIN
)
assert len(implementations) == 1
self.flow_impl = list(implementations.values())[0]
self.external_data = info
return await super().async_step_creation(info)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle external yaml configuration."""
if not self._reauth and self._async_current_entries():
return self.async_abort(reason="already_configured")
return await super().async_step_user(user_input)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Create an entry for auth."""
# The default behavior from the parent class is to redirect the
# user with an external step. When using the device flow, we instead
# prompt the user to visit a URL and enter a code. The device flow
# background task will poll the exchange endpoint to get valid
# creds or until a timeout is complete.
if user_input is not None:
return self.async_show_progress_done(next_step_id="creation")
if not self._device_flow:
_LOGGER.debug("Creating DeviceAuth flow")
try:
device_flow = await async_create_device_flow(self.hass)
except OAuthError as err:
_LOGGER.error("Error initializing device flow: %s", str(err))
return self.async_abort(reason="oauth_error")
self._device_flow = device_flow
async def _exchange_finished(creds: Credentials | None) -> None:
self.external_data = {"creds": creds} # is None on timeout/expiration
self.hass.async_create_task(
self.hass.config_entries.flow.async_configure(
flow_id=self.flow_id, user_input={}
)
)
await device_flow.start_exchange_task(_exchange_finished)
return self.async_show_progress(
step_id="auth",
description_placeholders={
"url": self._device_flow.verification_url,
"user_code": self._device_flow.user_code,
},
progress_action="exchange",
)
async def async_step_creation(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle external yaml configuration."""
if self.external_data.get("creds") is None:
return self.async_abort(reason="code_expired")
return await super().async_step_creation(user_input)
async def async_oauth_create_entry(self, data: dict) -> FlowResult:
"""Create an entry for the flow, or update existing entry."""
existing_entries = self._async_current_entries()
if existing_entries:
assert len(existing_entries) == 1
entry = existing_entries[0]
self.hass.config_entries.async_update_entry(entry, data=data)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(title=self.flow_impl.name, data=data)
async def async_step_reauth(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self._reauth = True
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm reauth dialog."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()

View File

@ -0,0 +1,30 @@
"""Constants for google integration."""
from __future__ import annotations
from enum import Enum
DOMAIN = "google"
DEVICE_AUTH_IMPL = "device_auth"
CONF_CALENDAR_ACCESS = "calendar_access"
DATA_CALENDARS = "calendars"
DATA_SERVICE = "service"
DATA_CONFIG = "config"
DISCOVER_CALENDAR = "google_discover_calendar"
class FeatureAccess(Enum):
"""Class to represent different access scopes."""
read_only = "https://www.googleapis.com/auth/calendar.readonly"
read_write = "https://www.googleapis.com/auth/calendar"
def __init__(self, scope: str) -> None:
"""Init instance."""
self._scope = scope
@property
def scope(self) -> str:
"""Google calendar scope for the feature."""
return self._scope

View File

@ -1,6 +1,8 @@
{
"domain": "google",
"name": "Google Calendars",
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/calendar.google/",
"requirements": [
"google-api-python-client==2.38.0",

View File

@ -0,0 +1,31 @@
{
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Nest integration needs to re-authenticate your account"
},
"auth": {
"title": "Link Google Account"
}
},
"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%]",
"code_expired": "Authentication code expired or credential setup is invalid, please try again.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"progress": {
"exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}"
}
}
}

View File

@ -0,0 +1,31 @@
{
"config": {
"abort": {
"already_configured": "Account is already configured",
"already_in_progress": "Configuration flow is already in progress",
"code_expired": "Authentication code expired, please try again.",
"invalid_access_token": "Invalid access token",
"missing_configuration": "The component is not configured. Please follow the documentation.",
"oauth_error": "Received invalid token data.",
"reauth_successful": "Re-authentication was successful"
},
"create_entry": {
"default": "Successfully authenticated"
},
"progress": {
"exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}"
},
"step": {
"auth": {
"title": "Link Google Account"
},
"pick_implementation": {
"title": "Pick Authentication Method"
},
"reauth_confirm": {
"description": "The Nest integration needs to re-authenticate your account",
"title": "Reauthenticate Integration"
}
}
}
}

View File

@ -125,6 +125,7 @@ FLOWS = [
"goalzero",
"gogogate2",
"goodwe",
"google",
"google_travel_time",
"gpslogger",
"gree",

View File

@ -18,6 +18,8 @@ from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.util.dt import utcnow
from tests.common import MockConfigEntry
ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
ApiResult = Callable[[dict[str, Any]], None]
@ -156,6 +158,25 @@ async def storage() -> YieldFixture[FakeStorage]:
yield storage
@pytest.fixture
async def config_entry(token_scopes: list[str]) -> MockConfigEntry:
"""Fixture to create a config entry for the integration."""
token_expiry = utcnow() + datetime.timedelta(days=7)
return MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": "device_auth",
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": " ".join(token_scopes),
"token_type": "Bearer",
"expires_at": token_expiry.timestamp(),
},
},
)
@pytest.fixture
async def mock_token_read(
hass: HomeAssistant,

View File

@ -53,10 +53,15 @@ TEST_EVENT = {
@pytest.fixture(autouse=True)
def mock_test_setup(
mock_calendars_yaml, test_api_calendar, mock_calendars_list, mock_token_read
hass,
mock_calendars_yaml,
test_api_calendar,
mock_calendars_list,
config_entry,
):
"""Fixture that pulls in the default fixtures for tests in this file."""
mock_calendars_list({"items": [test_api_calendar]})
config_entry.add_to_hass(hass)
return
@ -300,12 +305,11 @@ async def test_update_error(
assert state.name == TEST_ENTITY_NAME
assert state.state == "on"
# Advance time to avoid throttling
# Advance time beyond update/throttle point
now += datetime.timedelta(minutes=30)
with patch(
"homeassistant.components.google.api.google_discovery.build"
) as mock, patch("homeassistant.util.utcnow", return_value=now):
mock.return_value.events.return_value.list.return_value.execute.return_value = {
"items": [
{
@ -417,3 +421,19 @@ async def test_opaque_event(
assert response.status == HTTPStatus.OK
events = await response.json()
assert (len(events) > 0) == expect_visible_event
async def test_scan_calendar_error(
hass,
calendar_resource,
component_setup,
test_api_calendar,
):
"""Test that the calendar update handles a server error."""
with patch(
"homeassistant.components.google.api.google_discovery.build",
side_effect=httplib2.ServerNotFoundError("unit test"),
):
assert await component_setup()
assert not hass.states.get(TEST_ENTITY)

View File

@ -0,0 +1,350 @@
"""Test the google config flow."""
import datetime
from unittest.mock import Mock, patch
from oauth2client.client import (
FlowExchangeError,
OAuth2Credentials,
OAuth2DeviceCodeError,
)
import pytest
from homeassistant import config_entries
from homeassistant.components.google.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
from .conftest import ComponentSetup, YieldFixture
from tests.common import MockConfigEntry, async_fire_time_changed
CODE_CHECK_INTERVAL = 1
CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)
@pytest.fixture(autouse=True)
async def request_setup(current_request_with_host) -> None:
"""Request setup."""
return
@pytest.fixture
async def code_expiration_delta() -> datetime.timedelta:
"""Fixture for code expiration time, defaulting to the future."""
return datetime.timedelta(minutes=3)
@pytest.fixture
async def mock_code_flow(
code_expiration_delta: datetime.timedelta,
) -> YieldFixture[Mock]:
"""Fixture for initiating OAuth flow."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
) as mock_flow:
mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta
mock_flow.return_value.interval = CODE_CHECK_INTERVAL
yield mock_flow
@pytest.fixture
async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]:
"""Fixture for mocking out the exchange for credentials."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds
) as mock:
yield mock
async def fire_alarm(hass, point_in_time):
"""Fire an alarm and wait for callbacks to run."""
with patch("homeassistant.util.dt.utcnow", return_value=point_in_time):
async_fire_time_changed(hass, point_in_time)
await hass.async_block_till_done()
async def test_full_flow(
hass: HomeAssistant,
mock_code_flow: Mock,
mock_exchange: Mock,
component_setup: ComponentSetup,
) -> None:
"""Test successful creds setup."""
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "progress"
assert result.get("step_id") == "auth"
assert "description_placeholders" in result
assert "url" in result["description_placeholders"]
with patch(
"homeassistant.components.google.async_setup_entry", return_value=True
) as mock_setup:
# Run one tick to invoke the credential exchange check
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"]
)
assert result.get("type") == "create_entry"
assert result.get("title") == "Configuration.yaml"
assert "data" in result
data = result["data"]
assert "token" in data
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": "device_auth",
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
}
assert len(mock_setup.mock_calls) == 1
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
async def test_code_error(
hass: HomeAssistant,
mock_code_flow: Mock,
component_setup: ComponentSetup,
) -> None:
"""Test successful creds setup."""
assert await component_setup()
with patch(
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
side_effect=OAuth2DeviceCodeError("Test Failure"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "abort"
assert result.get("reason") == "oauth_error"
@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)])
async def test_expired_after_exchange(
hass: HomeAssistant,
mock_code_flow: Mock,
component_setup: ComponentSetup,
) -> None:
"""Test successful creds setup."""
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "progress"
assert result.get("step_id") == "auth"
assert "description_placeholders" in result
assert "url" in result["description_placeholders"]
# Run one tick to invoke the credential exchange check
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"])
assert result.get("type") == "abort"
assert result.get("reason") == "code_expired"
async def test_exchange_error(
hass: HomeAssistant,
mock_code_flow: Mock,
mock_exchange: Mock,
component_setup: ComponentSetup,
) -> None:
"""Test an error while exchanging the code for credentials."""
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "progress"
assert result.get("step_id") == "auth"
assert "description_placeholders" in result
assert "url" in result["description_placeholders"]
# Run one tick to invoke the credential exchange check
now = utcnow()
with patch(
"oauth2client.client.OAuth2WebServerFlow.step2_exchange",
side_effect=FlowExchangeError(),
):
now += CODE_CHECK_ALARM_TIMEDELTA
await fire_alarm(hass, now)
await hass.async_block_till_done()
# Status has not updated, will retry
result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"])
assert result.get("type") == "progress"
assert result.get("step_id") == "auth"
# Run another tick, which attempts credential exchange again
with patch(
"homeassistant.components.google.async_setup_entry", return_value=True
) as mock_setup:
now += CODE_CHECK_ALARM_TIMEDELTA
await fire_alarm(hass, now)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"]
)
assert result.get("type") == "create_entry"
assert result.get("title") == "Configuration.yaml"
assert "data" in result
data = result["data"]
assert "token" in data
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": "device_auth",
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
}
assert len(mock_setup.mock_calls) == 1
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
async def test_existing_config_entry(
hass: HomeAssistant,
config_entry: MockConfigEntry,
component_setup: ComponentSetup,
) -> None:
"""Test can't configure when config entry already exists."""
config_entry.add_to_hass(hass)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "abort"
assert result.get("reason") == "already_configured"
async def test_missing_configuration(
hass: HomeAssistant,
) -> None:
"""Test can't configure when config entry already exists."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == "abort"
assert result.get("reason") == "missing_configuration"
async def test_import_config_entry_from_existing_token(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
) -> None:
"""Test setup with an existing token file."""
assert await component_setup()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
data = entries[0].data
assert "token" in data
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": "device_auth",
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
}
async def test_reauth_flow(
hass: HomeAssistant,
mock_code_flow: Mock,
mock_exchange: Mock,
component_setup: ComponentSetup,
) -> None:
"""Test can't configure when config entry already exists."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": "device_auth",
"token": {"access_token": "OLD_ACCESS_TOKEN"},
},
)
config_entry.add_to_hass(hass)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=config_entry.data
)
assert result["type"] == "form"
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"],
user_input={},
)
assert result.get("type") == "progress"
assert result.get("step_id") == "auth"
assert "description_placeholders" in result
assert "url" in result["description_placeholders"]
with patch(
"homeassistant.components.google.async_setup_entry", return_value=True
) as mock_setup:
# Run one tick to invoke the credential exchange check
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"]
)
assert result.get("type") == "abort"
assert result.get("reason") == "reauth_successful"
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
data = entries[0].data
assert "token" in data
data["token"].pop("expires_at")
data["token"].pop("expires_in")
assert data == {
"auth_implementation": "device_auth",
"token": {
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer",
},
}
assert len(mock_setup.mock_calls) == 1

View File

@ -6,11 +6,6 @@ import datetime
from typing import Any
from unittest.mock import Mock, call, patch
from oauth2client.client import (
FlowExchangeError,
OAuth2Credentials,
OAuth2DeviceCodeError,
)
import pytest
from homeassistant.components.google import (
@ -18,6 +13,7 @@ from homeassistant.components.google import (
SERVICE_ADD_EVENT,
SERVICE_SCAN_CALENDARS,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant, State
from homeassistant.util.dt import utcnow
@ -30,73 +26,13 @@ from .conftest import (
TEST_YAML_ENTITY_NAME,
ApiResult,
ComponentSetup,
YieldFixture,
)
from tests.common import async_fire_time_changed
from tests.common import MockConfigEntry
# Typing helpers
HassApi = Callable[[], Awaitable[dict[str, Any]]]
CODE_CHECK_INTERVAL = 1
CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)
@pytest.fixture
async def code_expiration_delta() -> datetime.timedelta:
"""Fixture for code expiration time, defaulting to the future."""
return datetime.timedelta(minutes=3)
@pytest.fixture
async def mock_code_flow(
code_expiration_delta: datetime.timedelta,
) -> YieldFixture[Mock]:
"""Fixture for initiating OAuth flow."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
) as mock_flow:
mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta
mock_flow.return_value.interval = CODE_CHECK_INTERVAL
yield mock_flow
@pytest.fixture
async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]:
"""Fixture for mocking out the exchange for credentials."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds
) as mock:
yield mock
@pytest.fixture
async def mock_notification() -> YieldFixture[Mock]:
"""Fixture for capturing persistent notifications."""
with patch("homeassistant.components.persistent_notification.create") as mock:
yield mock
async def fire_alarm(hass, point_in_time):
"""Fire an alarm and wait for callbacks to run."""
with patch("homeassistant.util.dt.utcnow", return_value=point_in_time):
async_fire_time_changed(hass, point_in_time)
await hass.async_block_till_done()
@pytest.mark.parametrize("config", [{}])
async def test_setup_config_empty(
hass: HomeAssistant,
component_setup: ComponentSetup,
mock_notification: Mock,
):
"""Test setup component with an empty configuruation."""
assert await component_setup()
mock_notification.assert_not_called()
assert not hass.states.get(TEST_YAML_ENTITY)
def assert_state(actual: State | None, expected: State | None) -> None:
"""Assert that the two states are equal."""
@ -108,118 +44,29 @@ def assert_state(actual: State | None, expected: State | None) -> None:
assert actual.attributes == expected.attributes
async def test_init_success(
@pytest.fixture
def setup_config_entry(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> MockConfigEntry:
"""Fixture to initialize the config entry."""
config_entry.add_to_hass(hass)
async def test_unload_entry(
hass: HomeAssistant,
mock_code_flow: Mock,
mock_exchange: Mock,
mock_notification: Mock,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_calendars_yaml: None,
component_setup: ComponentSetup,
setup_config_entry: MockConfigEntry,
) -> None:
"""Test successful creds setup."""
mock_calendars_list({"items": [test_api_calendar]})
assert await component_setup()
"""Test load and unload of a ConfigEntry."""
await component_setup()
# Run one tick to invoke the credential exchange check
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
assert entry.state is ConfigEntryState.LOADED
state = hass.states.get(TEST_YAML_ENTITY)
assert state
assert state.name == TEST_YAML_ENTITY_NAME
assert state.state == STATE_OFF
mock_notification.assert_called()
assert "We are all setup now" in mock_notification.call_args[0][1]
async def test_code_error(
hass: HomeAssistant,
mock_code_flow: Mock,
component_setup: ComponentSetup,
mock_notification: Mock,
) -> None:
"""Test loading the integration with no existing credentials."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
side_effect=OAuth2DeviceCodeError("Test Failure"),
):
assert await component_setup()
assert not hass.states.get(TEST_YAML_ENTITY)
mock_notification.assert_called()
assert "Error: Test Failure" in mock_notification.call_args[0][1]
@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)])
async def test_expired_after_exchange(
hass: HomeAssistant,
mock_code_flow: Mock,
component_setup: ComponentSetup,
mock_notification: Mock,
) -> None:
"""Test loading the integration with no existing credentials."""
assert await component_setup()
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
assert not hass.states.get(TEST_YAML_ENTITY)
mock_notification.assert_called()
assert (
"Authentication code expired, please restart Home-Assistant and try again"
in mock_notification.call_args[0][1]
)
async def test_exchange_error(
hass: HomeAssistant,
mock_code_flow: Mock,
component_setup: ComponentSetup,
mock_notification: Mock,
) -> None:
"""Test an error while exchanging the code for credentials."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step2_exchange",
side_effect=FlowExchangeError(),
):
assert await component_setup()
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
assert not hass.states.get(TEST_YAML_ENTITY)
mock_notification.assert_called()
assert "In order to authorize Home-Assistant" in mock_notification.call_args[0][1]
async def test_existing_token(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_yaml: None,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_notification: Mock,
) -> None:
"""Test setup with an existing token file."""
mock_calendars_list({"items": [test_api_calendar]})
assert await component_setup()
state = hass.states.get(TEST_YAML_ENTITY)
assert state
assert state.name == TEST_YAML_ENTITY_NAME
assert state.state == STATE_OFF
mock_notification.assert_not_called()
assert await hass.config_entries.async_unload(entry.entry_id)
assert entry.state == ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
@ -228,80 +75,61 @@ async def test_existing_token(
async def test_existing_token_missing_scope(
hass: HomeAssistant,
token_scopes: list[str],
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_yaml: None,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_notification: Mock,
mock_code_flow: Mock,
mock_exchange: Mock,
config_entry: MockConfigEntry,
) -> None:
"""Test setup where existing token does not have sufficient scopes."""
mock_calendars_list({"items": [test_api_calendar]})
config_entry.add_to_hass(hass)
assert await component_setup()
# Run one tick to invoke the credential exchange check
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
assert len(mock_exchange.mock_calls) == 1
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.SETUP_ERROR
state = hass.states.get(TEST_YAML_ENTITY)
assert state
assert state.name == TEST_YAML_ENTITY_NAME
assert state.state == STATE_OFF
# No notifications on success
mock_notification.assert_called()
assert "We are all setup now" in mock_notification.call_args[0][1]
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "reauth_confirm"
@pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]])
async def test_calendar_yaml_missing_required_fields(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
calendars_config: list[dict[str, Any]],
mock_calendars_yaml: None,
mock_notification: Mock,
setup_config_entry: MockConfigEntry,
) -> None:
"""Test setup with a missing schema fields, ignores the error and continues."""
assert await component_setup()
assert not hass.states.get(TEST_YAML_ENTITY)
mock_notification.assert_not_called()
@pytest.mark.parametrize("calendars_config", [[{"missing-cal_id": "invalid-schema"}]])
async def test_invalid_calendar_yaml(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
calendars_config: list[dict[str, Any]],
mock_calendars_yaml: None,
mock_notification: Mock,
setup_config_entry: MockConfigEntry,
) -> None:
"""Test setup with missing entity id fields fails to setup the integration."""
"""Test setup with missing entity id fields fails to setup the config entry."""
# Integration fails to setup
assert not await component_setup()
assert await component_setup()
# XXX No config entries
assert not hass.states.get(TEST_YAML_ENTITY)
mock_notification.assert_not_called()
async def test_calendar_yaml_error(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_notification: Mock,
setup_config_entry: MockConfigEntry,
) -> None:
"""Test setup with yaml file not found."""
mock_calendars_list({"items": [test_api_calendar]})
with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()):
@ -344,12 +172,12 @@ async def test_calendar_yaml_error(
)
async def test_track_new(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_calendars_yaml: None,
expected_state: State,
setup_config_entry: MockConfigEntry,
) -> None:
"""Test behavior of configuration.yaml settings for tracking new calendars not in the config."""
@ -363,11 +191,11 @@ async def test_track_new(
@pytest.mark.parametrize("calendars_config", [[]])
async def test_found_calendar_from_api(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_yaml: None,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
setup_config_entry: MockConfigEntry,
) -> None:
"""Test finding a calendar from the API."""
@ -402,13 +230,13 @@ async def test_found_calendar_from_api(
)
async def test_calendar_config_track_new(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_yaml: None,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
calendars_config_track: bool,
expected_state: State,
setup_config_entry: MockConfigEntry,
) -> None:
"""Test calendar config that overrides whether or not a calendar is tracked."""
@ -421,11 +249,11 @@ async def test_calendar_config_track_new(
async def test_add_event(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_insert_event: Mock,
setup_config_entry: MockConfigEntry,
) -> None:
"""Test service call that adds an event."""
@ -471,7 +299,6 @@ async def test_add_event(
)
async def test_add_event_date_in_x(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
@ -479,6 +306,7 @@ async def test_add_event_date_in_x(
date_fields: dict[str, Any],
start_timedelta: datetime.timedelta,
end_timedelta: datetime.timedelta,
setup_config_entry: MockConfigEntry,
) -> None:
"""Test service call that adds an event with various time ranges."""
@ -514,10 +342,10 @@ async def test_add_event_date_in_x(
async def test_add_event_date(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
mock_insert_event: Mock,
setup_config_entry: MockConfigEntry,
) -> None:
"""Test service call that sets a date range."""
@ -554,11 +382,11 @@ async def test_add_event_date(
async def test_add_event_date_time(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_insert_event: Mock,
setup_config_entry: MockConfigEntry,
) -> None:
"""Test service call that adds an event with a date time range."""
@ -601,10 +429,10 @@ async def test_add_event_date_time(
async def test_scan_calendars(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
setup_config_entry: MockConfigEntry,
) -> None:
"""Test finding a calendar from the API."""