Deprecate google calendar configuration.yaml (#72288)

* Deprecate google calendar configuration.yaml

* Remove unused translations

* Enable strict type checking and address pr feedback

* Move default hass.data init to `async_setup`
This commit is contained in:
Allen Porter 2022-05-22 14:29:11 -07:00 committed by GitHub
parent 9c3f949165
commit e6ffae8bd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 267 additions and 47 deletions

View File

@ -101,6 +101,7 @@ homeassistant.components.geo_location.*
homeassistant.components.geocaching.*
homeassistant.components.gios.*
homeassistant.components.goalzero.*
homeassistant.components.google.*
homeassistant.components.greeneye_monitor.*
homeassistant.components.group.*
homeassistant.components.guardian.*

View File

@ -97,23 +97,27 @@ PLATFORMS = ["calendar"]
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_CLIENT_SECRET): cv.string,
vol.Optional(CONF_TRACK_NEW, default=True): cv.boolean,
vol.Optional(CONF_CALENDAR_ACCESS, default="read_write"): cv.enum(
FeatureAccess
),
}
)
},
vol.All(
cv.deprecated(DOMAIN),
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_CLIENT_SECRET): cv.string,
vol.Optional(CONF_TRACK_NEW, default=True): cv.boolean,
vol.Optional(CONF_CALENDAR_ACCESS, default="read_write"): cv.enum(
FeatureAccess
),
}
)
},
),
extra=vol.ALLOW_EXTRA,
)
_SINGLE_CALSEARCH_CONFIG = vol.All(
cv.deprecated(CONF_MAX_RESULTS),
cv.deprecated(CONF_TRACK),
vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
@ -160,6 +164,9 @@ ADD_EVENT_SERVICE_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Google component."""
if DOMAIN not in config:
return True
conf = config.get(DOMAIN, {})
hass.data[DOMAIN] = {DATA_CONFIG: conf}
@ -189,11 +196,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
},
)
)
_LOGGER.warning(
"Configuration of Google Calendar in YAML in configuration.yaml is "
"is deprecated and will be removed in a future release; Your existing "
"OAuth Application Credentials and other settings have been imported "
"into the UI automatically and can be safely removed from your "
"configuration.yaml file"
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google from a config entry."""
hass.data.setdefault(DOMAIN, {})
async_upgrade_entry(hass, entry)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
@ -216,8 +234,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
access = get_feature_access(hass)
if access.scope not in session.token.get("scope", []):
access = FeatureAccess[entry.options[CONF_CALENDAR_ACCESS]]
token_scopes = session.token.get("scope", [])
if access.scope not in token_scopes:
_LOGGER.debug("Scope '%s' not in scopes '%s'", access.scope, token_scopes)
raise ConfigEntryAuthFailed(
"Required scopes are not available, reauth required"
)
@ -226,25 +246,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
hass.data[DOMAIN][DATA_SERVICE] = calendar_service
track_new = hass.data[DOMAIN][DATA_CONFIG].get(CONF_TRACK_NEW, True)
await async_setup_services(hass, track_new, calendar_service)
await async_setup_services(hass, calendar_service)
# Only expose the add event service if we have the correct permissions
if access is FeatureAccess.read_write:
await async_setup_add_event_service(hass, calendar_service)
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
# Reload entry when options are updated
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True
def async_upgrade_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Upgrade the config entry if needed."""
if DATA_CONFIG not in hass.data[DOMAIN] and entry.options:
return
options = (
entry.options
if entry.options
else {
CONF_CALENDAR_ACCESS: get_feature_access(hass).name,
}
)
disable_new_entities = (
not hass.data[DOMAIN].get(DATA_CONFIG, {}).get(CONF_TRACK_NEW, True)
)
hass.config_entries.async_update_entry(
entry,
options=options,
pref_disable_new_entities=disable_new_entities,
)
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_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload the config entry when it changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_setup_services(
hass: HomeAssistant,
track_new: bool,
calendar_service: GoogleCalendarService,
) -> None:
"""Set up the service listeners."""
@ -256,10 +304,7 @@ async def async_setup_services(
async def _found_calendar(calendar_item: Calendar) -> None:
calendar = get_calendar_info(
hass,
{
**calendar_item.dict(exclude_unset=True),
CONF_TRACK: track_new,
},
calendar_item.dict(exclude_unset=True),
)
calendar_id = calendar_item.id
# Populate the yaml file with all discovered calendars
@ -363,7 +408,6 @@ def get_calendar_info(
CONF_CAL_ID: calendar["id"],
CONF_ENTITIES: [
{
CONF_TRACK: calendar["track"],
CONF_NAME: calendar["summary"],
CONF_DEVICE_ID: generate_entity_id(
"{}", calendar["summary"], hass=hass

View File

@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
import datetime
import logging
import time
from typing import Any
from typing import Any, cast
import aiohttp
from gcal_sync.auth import AbstractAuth
@ -76,12 +76,12 @@ class DeviceFlow:
@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
return self._device_flow_info.verification_url # type: ignore[no-any-return]
@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
return self._device_flow_info.user_code # type: ignore[no-any-return]
async def start_exchange_task(
self, finished_cb: Callable[[Credentials | None], Awaitable[None]]
@ -131,10 +131,13 @@ def get_feature_access(hass: HomeAssistant) -> FeatureAccess:
"""Return the desired calendar feature access."""
# This may be called during config entry setup without integration setup running when there
# is no google entry in configuration.yaml
return (
hass.data.get(DOMAIN, {})
.get(DATA_CONFIG, {})
.get(CONF_CALENDAR_ACCESS, DEFAULT_FEATURE_ACCESS)
return cast(
FeatureAccess,
(
hass.data.get(DOMAIN, {})
.get(DATA_CONFIG, {})
.get(CONF_CALENDAR_ACCESS, DEFAULT_FEATURE_ACCESS)
),
)
@ -157,7 +160,7 @@ async def async_create_device_flow(
return DeviceFlow(hass, oauth_flow, device_flow_info)
class ApiAuthImpl(AbstractAuth):
class ApiAuthImpl(AbstractAuth): # type: ignore[misc]
"""Authentication implementation for google calendar api library."""
def __init__(
@ -172,10 +175,10 @@ class ApiAuthImpl(AbstractAuth):
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
await self._session.async_ensure_token_valid()
return self._session.token["access_token"]
return cast(str, self._session.token["access_token"])
class AccessTokenAuthImpl(AbstractAuth):
class AccessTokenAuthImpl(AbstractAuth): # type: ignore[misc]
"""Authentication implementation used during config flow, without refresh.
This exists to allow the config flow to use the API before it has fully

View File

@ -1,4 +1,5 @@
"""Support for Google Calendar Search binary sensors."""
from __future__ import annotations
import copy
@ -89,14 +90,25 @@ def _async_setup_entities(
) -> None:
calendar_service = hass.data[DOMAIN][DATA_SERVICE]
entities = []
num_entities = len(disc_info[CONF_ENTITIES])
for data in disc_info[CONF_ENTITIES]:
if not data[CONF_TRACK]:
continue
entity_id = generate_entity_id(
ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass
)
entity_enabled = data.get(CONF_TRACK, True)
entity_name = data[CONF_DEVICE_ID]
entity_id = generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass)
calendar_id = disc_info[CONF_CAL_ID]
if num_entities > 1:
# The google_calendars.yaml file lets users add multiple entities for
# the same calendar id and needs additional disambiguation
unique_id = f"{calendar_id}-{entity_name}"
else:
unique_id = calendar_id
entity = GoogleCalendarEntity(
calendar_service, disc_info[CONF_CAL_ID], data, entity_id
calendar_service,
disc_info[CONF_CAL_ID],
data,
entity_id,
unique_id,
entity_enabled,
)
entities.append(entity)
@ -112,6 +124,8 @@ class GoogleCalendarEntity(CalendarEntity):
calendar_id: str,
data: dict[str, Any],
entity_id: str,
unique_id: str,
entity_enabled: bool,
) -> None:
"""Create the Calendar event device."""
self._calendar_service = calendar_service
@ -123,6 +137,8 @@ class GoogleCalendarEntity(CalendarEntity):
self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET)
self._offset_value: timedelta | None = None
self.entity_id = entity_id
self._attr_unique_id = unique_id
self._attr_entity_registry_enabled_default = entity_enabled
@property
def extra_state_attributes(self) -> dict[str, bool]:
@ -152,7 +168,7 @@ class GoogleCalendarEntity(CalendarEntity):
"""Return True if the event is visible."""
if self._ignore_availability:
return True
return event.transparency == OPAQUE
return event.transparency == OPAQUE # type: ignore[no-any-return]
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime

View File

@ -7,7 +7,10 @@ from typing import Any
from gcal_sync.api import GoogleCalendarService
from gcal_sync.exceptions import ApiException
from oauth2client.client import Credentials
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -21,7 +24,7 @@ from .api import (
async_create_device_flow,
get_feature_access,
)
from .const import DOMAIN
from .const import CONF_CALENDAR_ACCESS, DOMAIN, FeatureAccess
_LOGGER = logging.getLogger(__name__)
@ -36,7 +39,7 @@ class OAuth2FlowHandler(
def __init__(self) -> None:
"""Set up instance."""
super().__init__()
self._reauth = False
self._reauth_config_entry: config_entries.ConfigEntry | None = None
self._device_flow: DeviceFlow | None = None
@property
@ -60,7 +63,7 @@ class OAuth2FlowHandler(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle external yaml configuration."""
if not self._reauth and self._async_current_entries():
if not self._reauth_config_entry and self._async_current_entries():
return self.async_abort(reason="already_configured")
return await super().async_step_user(user_input)
@ -84,12 +87,17 @@ class OAuth2FlowHandler(
self.flow_impl,
)
return self.async_abort(reason="oauth_error")
calendar_access = get_feature_access(self.hass)
if self._reauth_config_entry and self._reauth_config_entry.options:
calendar_access = FeatureAccess[
self._reauth_config_entry.options[CONF_CALENDAR_ACCESS]
]
try:
device_flow = await async_create_device_flow(
self.hass,
self.flow_impl.client_id,
self.flow_impl.client_secret,
get_feature_access(self.hass),
calendar_access,
)
except OAuthError as err:
_LOGGER.error("Error initializing device flow: %s", str(err))
@ -146,13 +154,21 @@ class OAuth2FlowHandler(
_LOGGER.debug("Error reading calendar primary calendar: %s", err)
primary_calendar = None
title = primary_calendar.id if primary_calendar else self.flow_impl.name
return self.async_create_entry(title=title, data=data)
return self.async_create_entry(
title=title,
data=data,
options={
CONF_CALENDAR_ACCESS: get_feature_access(self.hass).name,
},
)
async def async_step_reauth(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self._reauth = True
self._reauth_config_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
@ -162,3 +178,43 @@ class OAuth2FlowHandler(
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Create an options flow."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Google Calendar options flow."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_CALENDAR_ACCESS,
default=self.config_entry.options.get(CONF_CALENDAR_ACCESS),
): vol.In(
{
"read_write": "Read/Write access (can create events)",
"read_only": "Read-only access",
}
)
}
),
)

View File

@ -27,5 +27,14 @@
"progress": {
"exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}"
}
},
"options": {
"step": {
"init": {
"data": {
"calendar_access": "Home Assistant access to Google Calendar"
}
}
}
}
}

View File

@ -27,5 +27,14 @@
"title": "Reauthenticate Integration"
}
}
},
"options": {
"step": {
"init": {
"data": {
"calendar_access": "Home Assistant access to Google Calendar"
}
}
}
}
}

View File

@ -874,6 +874,17 @@ no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.google.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.greeneye_monitor.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -21,6 +21,7 @@ from homeassistant.components.application_credentials import (
async_import_client_credential,
)
from homeassistant.components.google.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.util.dt import utcnow
@ -143,6 +144,7 @@ async def test_full_flow_yaml_creds(
"token_type": "Bearer",
},
}
assert result.get("options") == {"calendar_access": "read_write"}
assert len(mock_setup.mock_calls) == 1
entries = hass.config_entries.async_entries(DOMAIN)
@ -205,6 +207,7 @@ async def test_full_flow_application_creds(
"token_type": "Bearer",
},
}
assert result.get("options") == {"calendar_access": "read_write"}
assert len(mock_setup.mock_calls) == 1
entries = hass.config_entries.async_entries(DOMAIN)
@ -441,7 +444,12 @@ async def test_reauth_flow(
assert await component_setup()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=config_entry.data
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": config_entry.entry_id,
},
data=config_entry.data,
)
assert result["type"] == "form"
assert result["step_id"] == "reauth_confirm"
@ -523,3 +531,66 @@ async def test_title_lookup_failure(
assert len(mock_setup.mock_calls) == 1
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
async def test_options_flow_triggers_reauth(
hass: HomeAssistant,
component_setup: ComponentSetup,
config_entry: MockConfigEntry,
) -> None:
"""Test load and unload of a ConfigEntry."""
config_entry.add_to_hass(hass)
await component_setup()
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.options == {"calendar_access": "read_write"}
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == "form"
assert result["step_id"] == "init"
data_schema = result["data_schema"].schema
assert set(data_schema) == {"calendar_access"}
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"calendar_access": "read_only",
},
)
assert result["type"] == "create_entry"
await hass.async_block_till_done()
assert config_entry.options == {"calendar_access": "read_only"}
# Re-auth flow was initiated because access level changed
assert config_entry.state is ConfigEntryState.SETUP_ERROR
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "reauth_confirm"
async def test_options_flow_no_changes(
hass: HomeAssistant,
component_setup: ComponentSetup,
config_entry: MockConfigEntry,
) -> None:
"""Test load and unload of a ConfigEntry."""
config_entry.add_to_hass(hass)
await component_setup()
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.options == {"calendar_access": "read_write"}
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == "form"
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"calendar_access": "read_write",
},
)
assert result["type"] == "create_entry"
await hass.async_block_till_done()
assert config_entry.options == {"calendar_access": "read_write"}
# Re-auth flow was initiated because access level changed
assert config_entry.state is ConfigEntryState.LOADED

View File

@ -46,7 +46,7 @@ HassApi = Callable[[], Awaitable[dict[str, Any]]]
def assert_state(actual: State | None, expected: State | None) -> None:
"""Assert that the two states are equal."""
if actual is None:
if actual is None or expected is None:
assert actual == expected
return
assert actual.entity_id == expected.entity_id