Skip to content

Commit 4fb74dd

Browse files
committed
Allow passing of json/yaml argument to service call
When calling service who needs arguments which are other things than strings, ex nested dicts, its easiest done by being able to pass a json or yaml argument, for example when calling html5.dismis: hass-cli service call html5.dismiss --json '{"target": "DEVICE", "data": {"tag": "TAG"}}' As requested in review, this also adds support for passing in @filename or - for reading arguments from stdin or from a file. Signed-off-by: Anton Lundin <glance@acc.umu.se>
1 parent 1528c0a commit 4fb74dd

File tree

3 files changed

+213
-7
lines changed

3 files changed

+213
-7
lines changed

homeassistant_cli/helper.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import shlex
77
from typing import Any, Dict, Generator, List, Optional, Tuple, Union, cast
8+
from typing import TextIO
89

910
from ruamel.yaml import YAML
1011
from tabulate import tabulate
@@ -16,7 +17,7 @@
1617
_LOGGING = logging.getLogger(__name__)
1718

1819

19-
def to_attributes(entry: str) -> Dict[str, str]:
20+
def to_attributes(entry: Union[str, TextIO]) -> Dict[str, str]:
2021
"""Convert list of key=value pairs to dictionary."""
2122
if not entry:
2223
return {}

homeassistant_cli/plugins/service.py

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import re as reg
44
import sys
55
from typing import Any, Dict, List, Pattern # noqa: F401
6+
import json
7+
import io
68

79
import click
810

@@ -11,6 +13,7 @@
1113
from homeassistant_cli.config import Configuration
1214
from homeassistant_cli.helper import format_output, to_attributes
1315
import homeassistant_cli.remote as api
16+
from homeassistant_cli.yaml import yaml
1417

1518
_LOGGING = logging.getLogger(__name__)
1619

@@ -75,17 +78,77 @@ def list_cmd(ctx: Configuration, servicefilter):
7578
)
7679

7780

81+
def argument_callback(ctx, param, value):
82+
_LOGGING.debug("argument_callback called, %s(%s)", param.name, value)
83+
84+
# We get called with value None
85+
# for all the callbacks which aren't provided.
86+
if value is None:
87+
return
88+
89+
if 'data' in ctx.params and ctx.params['data'] is not None:
90+
_LOGGING.error("You can only specify one type of the argument types!")
91+
_LOGGING.debug(ctx.params)
92+
ctx.exit()
93+
94+
if value == '-': # read from stdin
95+
_LOGGING.debug("Loading value from stdin")
96+
value = sys.stdin
97+
elif value.startswith('@'): # read from file
98+
_LOGGING.debug("Loading value from file: %s", value[1:])
99+
value = open(value[1:], 'r')
100+
else:
101+
_LOGGING.debug("Using value as is: %s", value)
102+
103+
if param.name == 'arguments':
104+
result = to_attributes(value)
105+
elif param.name == 'json':
106+
# We need to use different json calls to load from stream or string
107+
if type(value) == str:
108+
result = json.loads(value)
109+
else:
110+
result = json.load(value)
111+
elif param.name == 'yaml':
112+
result = yaml().load(value)
113+
else:
114+
_LOGGING.error("Parameter name is unknown:", param.name)
115+
ctx.exit()
116+
117+
if isinstance(value, io.IOBase):
118+
value.close()
119+
120+
ctx.params['data'] = result
121+
122+
78123
@cli.command('call')
79124
@click.argument(
80125
'service',
81126
required=True,
82127
autocompletion=autocompletion.services, # type: ignore
83128
)
84129
@click.option(
85-
'--arguments', help="Comma separated key/value pairs to use as arguments."
130+
'--arguments', help="""Comma separated key/value pairs to use as arguments.
131+
if string is -, the data is read from stdin, and if it starts with the letter @
132+
the rest should be a filename from which the data is read""",
133+
callback=argument_callback,
134+
expose_value=False
135+
)
136+
@click.option(
137+
'--json', help="""Json string to use as arguments.
138+
if string is -, the data is read from stdin, and if it starts with the letter @
139+
the rest should be a filename from which the data is read""",
140+
callback=argument_callback,
141+
expose_value=False
142+
)
143+
@click.option(
144+
'--yaml', help="""Yaml string to use as arguments.
145+
if string is -, the data is read from stdin, and if it starts with the letter @
146+
the rest should be a filename from which the data is read""",
147+
callback=argument_callback,
148+
expose_value=False
86149
)
87150
@pass_context
88-
def call(ctx: Configuration, service, arguments):
151+
def call(ctx: Configuration, service, data=None):
89152
"""Call a service."""
90153
ctx.auto_output('data')
91154
_LOGGING.debug("service call <start>")
@@ -94,10 +157,7 @@ def call(ctx: Configuration, service, arguments):
94157
_LOGGING.error("Service name not following <domain>.<service> format")
95158
sys.exit(1)
96159

97-
_LOGGING.debug("Convert arguments %s to dict", arguments)
98-
data = to_attributes(arguments)
99-
100-
_LOGGING.debug("service call_service")
160+
_LOGGING.debug("calling %s.%s(%s)", parts[0], parts[1], data)
101161

102162
result = api.call_service(ctx, parts[0], parts[1], data)
103163

tests/test_service.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33

44
from click.testing import CliRunner
55
import requests_mock
6+
from unittest.mock import mock_open, patch
67

78
import homeassistant_cli.autocompletion as autocompletion
89
import homeassistant_cli.cli as cli
910
from homeassistant_cli.config import Configuration
11+
from homeassistant_cli.yaml import dumpyaml
1012

1113

1214
def test_service_list(default_services) -> None:
@@ -91,3 +93,146 @@ def test_service_call(default_services) -> None:
9193
assert result.exit_code == 0
9294

9395
assert post.call_count == 1
96+
97+
98+
def test_service_call_with_arguments(default_services) -> None:
99+
"""Test basic call of a service."""
100+
with requests_mock.Mocker() as mock:
101+
102+
post = mock.post(
103+
"http://localhost:8123/api/services/homeassistant/restart",
104+
json={"result": "bogus"},
105+
status_code=200,
106+
)
107+
108+
runner = CliRunner()
109+
result = runner.invoke(
110+
cli.cli,
111+
["--output=json", "service", "call", "homeassistant.restart",
112+
"--arguments", "foo=bar,test=call"],
113+
catch_exceptions=False,
114+
)
115+
116+
assert result.exit_code == 0
117+
118+
assert post.call_count == 1
119+
120+
assert post.last_request.json() == {"foo": "bar", "test": "call"}
121+
122+
123+
def test_service_call_with_json(default_services) -> None:
124+
"""Test basic call of a service."""
125+
with requests_mock.Mocker() as mock:
126+
127+
post = mock.post(
128+
"http://localhost:8123/api/services/homeassistant/restart",
129+
json={"result": "bogus"},
130+
status_code=200,
131+
)
132+
133+
data = {"foo": "bar", "test": True}
134+
135+
runner = CliRunner()
136+
result = runner.invoke(
137+
cli.cli,
138+
["--output=json", "service", "call", "homeassistant.restart",
139+
"--json", json.dumps(data)],
140+
catch_exceptions=False,
141+
)
142+
143+
assert result.exit_code == 0
144+
145+
assert post.call_count == 1
146+
147+
assert post.last_request.json() == data
148+
149+
150+
def test_service_call_with_json_stdin(default_services) -> None:
151+
"""Test basic call of a service."""
152+
with requests_mock.Mocker() as mock:
153+
154+
post = mock.post(
155+
"http://localhost:8123/api/services/homeassistant/restart",
156+
json={"result": "bogus"},
157+
status_code=200,
158+
)
159+
160+
data = {
161+
"foo": "bar",
162+
"test": True,
163+
}
164+
165+
runner = CliRunner()
166+
result = runner.invoke(
167+
cli.cli,
168+
["--output=json", "service", "call", "homeassistant.restart",
169+
"--json", "-"],
170+
catch_exceptions=False,
171+
input=json.dumps(data)
172+
)
173+
174+
assert result.exit_code == 0
175+
176+
assert post.call_count == 1
177+
178+
assert post.last_request.json() == data
179+
180+
181+
def test_service_call_with_yaml(default_services) -> None:
182+
"""Test basic call of a service."""
183+
with requests_mock.Mocker() as mock:
184+
185+
post = mock.post(
186+
"http://localhost:8123/api/services/homeassistant/restart",
187+
json={"result": "bogus"},
188+
status_code=200,
189+
)
190+
191+
runner = CliRunner()
192+
result = runner.invoke(
193+
cli.cli,
194+
["--output=json", "service", "call", "homeassistant.restart",
195+
"--yaml", "foo: bar"],
196+
catch_exceptions=False,
197+
)
198+
199+
assert result.exit_code == 0
200+
201+
assert post.call_count == 1
202+
203+
assert post.last_request.json() == {"foo": "bar"}
204+
205+
206+
def test_service_call_with_yaml_file(default_services) -> None:
207+
"""Test basic call of a service."""
208+
with requests_mock.Mocker() as mock:
209+
210+
post = mock.post(
211+
"http://localhost:8123/api/services/homeassistant/restart",
212+
json={"result": "bogus"},
213+
status_code=200,
214+
)
215+
216+
data = {
217+
"foo": "bar",
218+
"test": True,
219+
}
220+
221+
open_yaml_file = mock_open(read_data=dumpyaml(None, data=data))
222+
223+
runner = CliRunner()
224+
with patch('builtins.open', open_yaml_file) as mocked_open:
225+
result = runner.invoke(
226+
cli.cli,
227+
["--output=json", "service", "call", "homeassistant.restart",
228+
"--yaml", "@yaml_file.yml"],
229+
catch_exceptions=False,
230+
)
231+
232+
assert result.exit_code == 0
233+
234+
assert post.call_count == 1
235+
236+
mocked_open.assert_called_once_with("yaml_file.yml", "r")
237+
238+
assert post.last_request.json() == data

0 commit comments

Comments
 (0)