1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-24 21:06:19 +00:00

Add google sheet get service (#150133)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Luca Angemi
2025-10-28 14:21:51 +01:00
committed by GitHub
parent 03f339b3a3
commit ce93de7fc6
6 changed files with 251 additions and 3 deletions

View File

@@ -2,6 +2,9 @@
"services": {
"append_sheet": {
"service": "mdi:google-spreadsheet"
},
"get_sheet": {
"service": "mdi:google-spreadsheet"
}
}
}

View File

@@ -12,11 +12,19 @@ from gspread.exceptions import APIError
from gspread.utils import ValueInputOption
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.util.json import JsonObjectType
from .const import DOMAIN
@@ -25,9 +33,11 @@ if TYPE_CHECKING:
DATA = "data"
DATA_CONFIG_ENTRY = "config_entry"
ROWS = "rows"
WORKSHEET = "worksheet"
SERVICE_APPEND_SHEET = "append_sheet"
SERVICE_GET_SHEET = "get_sheet"
SHEET_SERVICE_SCHEMA = vol.All(
{
@@ -37,6 +47,14 @@ SHEET_SERVICE_SCHEMA = vol.All(
},
)
get_SHEET_SERVICE_SCHEMA = vol.All(
{
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Optional(WORKSHEET): cv.string,
vol.Required(ROWS): cv.positive_int,
},
)
def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
"""Run append in the executor."""
@@ -65,6 +83,24 @@ def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
worksheet.append_rows(rows, value_input_option=ValueInputOption.user_entered)
def _get_from_sheet(
call: ServiceCall, entry: GoogleSheetsConfigEntry
) -> JsonObjectType:
"""Run get in the executor."""
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
try:
sheet = service.open_by_key(entry.unique_id)
except RefreshError:
entry.async_start_reauth(call.hass)
raise
except APIError as ex:
raise HomeAssistantError("Failed to retrieve data") from ex
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
all_values = worksheet.get_values()
return {"range": all_values[-call.data[ROWS] :]}
async def _async_append_to_sheet(call: ServiceCall) -> None:
"""Append new line of data to a Google Sheets document."""
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
@@ -76,6 +112,22 @@ async def _async_append_to_sheet(call: ServiceCall) -> None:
await call.hass.async_add_executor_job(_append_to_sheet, call, entry)
async def _async_get_from_sheet(call: ServiceCall) -> ServiceResponse:
"""Get lines of data from a Google Sheets document."""
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
call.data[DATA_CONFIG_ENTRY]
)
if entry is None:
raise ServiceValidationError(
f"Invalid config entry id: {call.data[DATA_CONFIG_ENTRY]}"
)
if entry.state is not ConfigEntryState.LOADED:
raise HomeAssistantError(f"Config entry {entry.entry_id} is not loaded")
await entry.runtime_data.async_ensure_token_valid()
return await call.hass.async_add_executor_job(_get_from_sheet, call, entry)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Add the services for Google Sheets."""
@@ -86,3 +138,11 @@ def async_setup_services(hass: HomeAssistant) -> None:
_async_append_to_sheet,
schema=SHEET_SERVICE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_SHEET,
_async_get_from_sheet,
schema=get_SHEET_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)

View File

@@ -14,3 +14,19 @@ append_sheet:
example: '{"hello": world, "cool": True, "count": 5}'
selector:
object:
get_sheet:
fields:
config_entry:
required: true
selector:
config_entry:
integration: google_sheets
worksheet:
example: "Sheet1"
selector:
text:
rows:
required: true
example: 2
selector:
number:

View File

@@ -59,6 +59,24 @@
}
},
"name": "Append to sheet"
},
"get_sheet": {
"description": "Gets data from a worksheet in Google Sheets.",
"fields": {
"config_entry": {
"description": "The sheet to get data from.",
"name": "[%key:component::google_sheets::services::append_sheet::fields::config_entry::name%]"
},
"rows": {
"description": "Maximum number of rows from the end of the worksheet to return.",
"name": "Rows"
},
"worksheet": {
"description": "[%key:component::google_sheets::services::append_sheet::fields::worksheet::description%]",
"name": "[%key:component::google_sheets::services::append_sheet::fields::worksheet::name%]"
}
},
"name": "Get data from sheet"
}
}
}

View File

@@ -0,0 +1,15 @@
# serializer version: 1
# name: test_get_sheet
dict({
'range': list([
list([
'a',
'b',
]),
list([
'c',
'd',
]),
]),
})
# ---

View File

@@ -9,15 +9,22 @@ from unittest.mock import patch
from gspread.exceptions import APIError
import pytest
from requests.models import Response
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.google_sheets.const import DOMAIN
from homeassistant.components.google_sheets.services import (
DATA_CONFIG_ENTRY,
ROWS,
SERVICE_GET_SHEET,
WORKSHEET,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@@ -213,6 +220,40 @@ async def test_append_sheet(
assert len(mock_client.mock_calls) == 8
async def test_get_sheet(
hass: HomeAssistant,
setup_integration: ComponentSetup,
config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test service call getting data from a sheet."""
await setup_integration()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED
with patch("homeassistant.components.google_sheets.services.Client") as mock_client:
mock_client.return_value.open_by_key.return_value.worksheet.return_value.get_values.return_value = [
["col1", "col2"],
["a", "b"],
["c", "d"],
]
response = await hass.services.async_call(
DOMAIN,
SERVICE_GET_SHEET,
{
DATA_CONFIG_ENTRY: config_entry.entry_id,
WORKSHEET: "Sheet1",
ROWS: 2,
},
blocking=True,
return_response=True,
)
assert len(mock_client.mock_calls) == 4
assert response == snapshot
async def test_append_sheet_multiple_rows(
hass: HomeAssistant,
setup_integration: ComponentSetup,
@@ -330,3 +371,98 @@ async def test_append_sheet_invalid_config_entry(
},
blocking=True,
)
async def test_get_sheet_invalid_config_entry(
hass: HomeAssistant,
setup_integration: ComponentSetup,
config_entry: MockConfigEntry,
expires_at: int,
scopes: list[str],
) -> None:
"""Test service call get sheet with invalid config entries."""
config_entry2 = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_SHEET_ID + "2",
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_at": expires_at,
"scope": " ".join(scopes),
},
},
)
config_entry2.add_to_hass(hass)
await setup_integration()
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry2.state is ConfigEntryState.LOADED
# Exercise service call on a config entry that does not exist
with pytest.raises(ServiceValidationError, match="Invalid config entry"):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_SHEET,
{
DATA_CONFIG_ENTRY: config_entry.entry_id + "XXX",
WORKSHEET: "Sheet1",
ROWS: 2,
},
blocking=True,
return_response=True,
)
# Unload the config entry invoke the service on the unloaded entry id
await hass.config_entries.async_unload(config_entry2.entry_id)
await hass.async_block_till_done()
assert config_entry2.state is ConfigEntryState.NOT_LOADED
async def test_get_sheet_invalid_worksheet(
hass: HomeAssistant,
setup_integration: ComponentSetup,
config_entry: MockConfigEntry,
expires_at: int,
scopes: list[str],
) -> None:
"""Test service call get sheet with invalid config entries."""
config_entry2 = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_SHEET_ID + "2",
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_at": expires_at,
"scope": " ".join(scopes),
},
},
)
config_entry2.add_to_hass(hass)
await setup_integration()
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry2.state is ConfigEntryState.LOADED
# Exercise service call on a worksheet that does not exist
with patch("homeassistant.components.google_sheets.services.Client") as mock_client:
mock_client.return_value.open_by_key.return_value.worksheet.side_effect = (
APIError(Response())
)
with pytest.raises(APIError):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_SHEET,
{
DATA_CONFIG_ENTRY: config_entry.entry_id,
WORKSHEET: "DoesNotExist",
ROWS: 2,
},
blocking=True,
return_response=True,
)