Skip to content

Commit ce93de7

Browse files
luca-angemiNoRi2909emontnemery
authored
Add google sheet get service (home-assistant#150133)
Co-authored-by: Norbert Rittel <[email protected]> Co-authored-by: Erik Montnemery <[email protected]>
1 parent 03f339b commit ce93de7

File tree

6 files changed

+251
-3
lines changed

6 files changed

+251
-3
lines changed

homeassistant/components/google_sheets/icons.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"services": {
33
"append_sheet": {
44
"service": "mdi:google-spreadsheet"
5+
},
6+
"get_sheet": {
7+
"service": "mdi:google-spreadsheet"
58
}
69
}
710
}

homeassistant/components/google_sheets/services.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,19 @@
1212
from gspread.utils import ValueInputOption
1313
import voluptuous as vol
1414

15+
from homeassistant.config_entries import ConfigEntryState
1516
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
16-
from homeassistant.core import HomeAssistant, ServiceCall, callback
17-
from homeassistant.exceptions import HomeAssistantError
17+
from homeassistant.core import (
18+
HomeAssistant,
19+
ServiceCall,
20+
ServiceResponse,
21+
SupportsResponse,
22+
callback,
23+
)
24+
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
1825
from homeassistant.helpers import config_validation as cv
1926
from homeassistant.helpers.selector import ConfigEntrySelector
27+
from homeassistant.util.json import JsonObjectType
2028

2129
from .const import DOMAIN
2230

@@ -25,9 +33,11 @@
2533

2634
DATA = "data"
2735
DATA_CONFIG_ENTRY = "config_entry"
36+
ROWS = "rows"
2837
WORKSHEET = "worksheet"
2938

3039
SERVICE_APPEND_SHEET = "append_sheet"
40+
SERVICE_GET_SHEET = "get_sheet"
3141

3242
SHEET_SERVICE_SCHEMA = vol.All(
3343
{
@@ -37,6 +47,14 @@
3747
},
3848
)
3949

50+
get_SHEET_SERVICE_SCHEMA = vol.All(
51+
{
52+
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
53+
vol.Optional(WORKSHEET): cv.string,
54+
vol.Required(ROWS): cv.positive_int,
55+
},
56+
)
57+
4058

4159
def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
4260
"""Run append in the executor."""
@@ -65,6 +83,24 @@ def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
6583
worksheet.append_rows(rows, value_input_option=ValueInputOption.user_entered)
6684

6785

86+
def _get_from_sheet(
87+
call: ServiceCall, entry: GoogleSheetsConfigEntry
88+
) -> JsonObjectType:
89+
"""Run get in the executor."""
90+
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
91+
try:
92+
sheet = service.open_by_key(entry.unique_id)
93+
except RefreshError:
94+
entry.async_start_reauth(call.hass)
95+
raise
96+
except APIError as ex:
97+
raise HomeAssistantError("Failed to retrieve data") from ex
98+
99+
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
100+
all_values = worksheet.get_values()
101+
return {"range": all_values[-call.data[ROWS] :]}
102+
103+
68104
async def _async_append_to_sheet(call: ServiceCall) -> None:
69105
"""Append new line of data to a Google Sheets document."""
70106
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
@@ -76,6 +112,22 @@ async def _async_append_to_sheet(call: ServiceCall) -> None:
76112
await call.hass.async_add_executor_job(_append_to_sheet, call, entry)
77113

78114

115+
async def _async_get_from_sheet(call: ServiceCall) -> ServiceResponse:
116+
"""Get lines of data from a Google Sheets document."""
117+
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
118+
call.data[DATA_CONFIG_ENTRY]
119+
)
120+
if entry is None:
121+
raise ServiceValidationError(
122+
f"Invalid config entry id: {call.data[DATA_CONFIG_ENTRY]}"
123+
)
124+
if entry.state is not ConfigEntryState.LOADED:
125+
raise HomeAssistantError(f"Config entry {entry.entry_id} is not loaded")
126+
127+
await entry.runtime_data.async_ensure_token_valid()
128+
return await call.hass.async_add_executor_job(_get_from_sheet, call, entry)
129+
130+
79131
@callback
80132
def async_setup_services(hass: HomeAssistant) -> None:
81133
"""Add the services for Google Sheets."""
@@ -86,3 +138,11 @@ def async_setup_services(hass: HomeAssistant) -> None:
86138
_async_append_to_sheet,
87139
schema=SHEET_SERVICE_SCHEMA,
88140
)
141+
142+
hass.services.async_register(
143+
DOMAIN,
144+
SERVICE_GET_SHEET,
145+
_async_get_from_sheet,
146+
schema=get_SHEET_SERVICE_SCHEMA,
147+
supports_response=SupportsResponse.ONLY,
148+
)

homeassistant/components/google_sheets/services.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,19 @@ append_sheet:
1414
example: '{"hello": world, "cool": True, "count": 5}'
1515
selector:
1616
object:
17+
get_sheet:
18+
fields:
19+
config_entry:
20+
required: true
21+
selector:
22+
config_entry:
23+
integration: google_sheets
24+
worksheet:
25+
example: "Sheet1"
26+
selector:
27+
text:
28+
rows:
29+
required: true
30+
example: 2
31+
selector:
32+
number:

homeassistant/components/google_sheets/strings.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@
5959
}
6060
},
6161
"name": "Append to sheet"
62+
},
63+
"get_sheet": {
64+
"description": "Gets data from a worksheet in Google Sheets.",
65+
"fields": {
66+
"config_entry": {
67+
"description": "The sheet to get data from.",
68+
"name": "[%key:component::google_sheets::services::append_sheet::fields::config_entry::name%]"
69+
},
70+
"rows": {
71+
"description": "Maximum number of rows from the end of the worksheet to return.",
72+
"name": "Rows"
73+
},
74+
"worksheet": {
75+
"description": "[%key:component::google_sheets::services::append_sheet::fields::worksheet::description%]",
76+
"name": "[%key:component::google_sheets::services::append_sheet::fields::worksheet::name%]"
77+
}
78+
},
79+
"name": "Get data from sheet"
6280
}
6381
}
6482
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# serializer version: 1
2+
# name: test_get_sheet
3+
dict({
4+
'range': list([
5+
list([
6+
'a',
7+
'b',
8+
]),
9+
list([
10+
'c',
11+
'd',
12+
]),
13+
]),
14+
})
15+
# ---

tests/components/google_sheets/test_init.py

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,22 @@
99
from gspread.exceptions import APIError
1010
import pytest
1111
from requests.models import Response
12+
from syrupy.assertion import SnapshotAssertion
1213

1314
from homeassistant.components.application_credentials import (
1415
ClientCredential,
1516
async_import_client_credential,
1617
)
1718
from homeassistant.components.google_sheets.const import DOMAIN
19+
from homeassistant.components.google_sheets.services import (
20+
DATA_CONFIG_ENTRY,
21+
ROWS,
22+
SERVICE_GET_SHEET,
23+
WORKSHEET,
24+
)
1825
from homeassistant.config_entries import ConfigEntryState
1926
from homeassistant.core import HomeAssistant
20-
from homeassistant.exceptions import HomeAssistantError
27+
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
2128
from homeassistant.setup import async_setup_component
2229

2330
from tests.common import MockConfigEntry
@@ -213,6 +220,40 @@ async def test_append_sheet(
213220
assert len(mock_client.mock_calls) == 8
214221

215222

223+
async def test_get_sheet(
224+
hass: HomeAssistant,
225+
setup_integration: ComponentSetup,
226+
config_entry: MockConfigEntry,
227+
snapshot: SnapshotAssertion,
228+
) -> None:
229+
"""Test service call getting data from a sheet."""
230+
await setup_integration()
231+
232+
entries = hass.config_entries.async_entries(DOMAIN)
233+
assert len(entries) == 1
234+
assert entries[0].state is ConfigEntryState.LOADED
235+
236+
with patch("homeassistant.components.google_sheets.services.Client") as mock_client:
237+
mock_client.return_value.open_by_key.return_value.worksheet.return_value.get_values.return_value = [
238+
["col1", "col2"],
239+
["a", "b"],
240+
["c", "d"],
241+
]
242+
response = await hass.services.async_call(
243+
DOMAIN,
244+
SERVICE_GET_SHEET,
245+
{
246+
DATA_CONFIG_ENTRY: config_entry.entry_id,
247+
WORKSHEET: "Sheet1",
248+
ROWS: 2,
249+
},
250+
blocking=True,
251+
return_response=True,
252+
)
253+
assert len(mock_client.mock_calls) == 4
254+
assert response == snapshot
255+
256+
216257
async def test_append_sheet_multiple_rows(
217258
hass: HomeAssistant,
218259
setup_integration: ComponentSetup,
@@ -330,3 +371,98 @@ async def test_append_sheet_invalid_config_entry(
330371
},
331372
blocking=True,
332373
)
374+
375+
376+
async def test_get_sheet_invalid_config_entry(
377+
hass: HomeAssistant,
378+
setup_integration: ComponentSetup,
379+
config_entry: MockConfigEntry,
380+
expires_at: int,
381+
scopes: list[str],
382+
) -> None:
383+
"""Test service call get sheet with invalid config entries."""
384+
config_entry2 = MockConfigEntry(
385+
domain=DOMAIN,
386+
unique_id=TEST_SHEET_ID + "2",
387+
data={
388+
"auth_implementation": DOMAIN,
389+
"token": {
390+
"access_token": "mock-access-token",
391+
"refresh_token": "mock-refresh-token",
392+
"expires_at": expires_at,
393+
"scope": " ".join(scopes),
394+
},
395+
},
396+
)
397+
config_entry2.add_to_hass(hass)
398+
399+
await setup_integration()
400+
401+
assert config_entry.state is ConfigEntryState.LOADED
402+
assert config_entry2.state is ConfigEntryState.LOADED
403+
404+
# Exercise service call on a config entry that does not exist
405+
with pytest.raises(ServiceValidationError, match="Invalid config entry"):
406+
await hass.services.async_call(
407+
DOMAIN,
408+
SERVICE_GET_SHEET,
409+
{
410+
DATA_CONFIG_ENTRY: config_entry.entry_id + "XXX",
411+
WORKSHEET: "Sheet1",
412+
ROWS: 2,
413+
},
414+
blocking=True,
415+
return_response=True,
416+
)
417+
418+
# Unload the config entry invoke the service on the unloaded entry id
419+
await hass.config_entries.async_unload(config_entry2.entry_id)
420+
await hass.async_block_till_done()
421+
assert config_entry2.state is ConfigEntryState.NOT_LOADED
422+
423+
424+
async def test_get_sheet_invalid_worksheet(
425+
hass: HomeAssistant,
426+
setup_integration: ComponentSetup,
427+
config_entry: MockConfigEntry,
428+
expires_at: int,
429+
scopes: list[str],
430+
) -> None:
431+
"""Test service call get sheet with invalid config entries."""
432+
config_entry2 = MockConfigEntry(
433+
domain=DOMAIN,
434+
unique_id=TEST_SHEET_ID + "2",
435+
data={
436+
"auth_implementation": DOMAIN,
437+
"token": {
438+
"access_token": "mock-access-token",
439+
"refresh_token": "mock-refresh-token",
440+
"expires_at": expires_at,
441+
"scope": " ".join(scopes),
442+
},
443+
},
444+
)
445+
config_entry2.add_to_hass(hass)
446+
447+
await setup_integration()
448+
449+
assert config_entry.state is ConfigEntryState.LOADED
450+
assert config_entry2.state is ConfigEntryState.LOADED
451+
452+
# Exercise service call on a worksheet that does not exist
453+
with patch("homeassistant.components.google_sheets.services.Client") as mock_client:
454+
mock_client.return_value.open_by_key.return_value.worksheet.side_effect = (
455+
APIError(Response())
456+
)
457+
with pytest.raises(APIError):
458+
await hass.services.async_call(
459+
DOMAIN,
460+
SERVICE_GET_SHEET,
461+
{
462+
DATA_CONFIG_ENTRY: config_entry.entry_id,
463+
WORKSHEET: "DoesNotExist",
464+
ROWS: 2,
465+
},
466+
blocking=True,
467+
return_response=True,
468+
)

0 commit comments

Comments
 (0)