Skip to content

Commit

Permalink
Allow passing of json/yaml argument to service call
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
glance- committed Jan 21, 2020
1 parent 1528c0a commit 123c1d2
Show file tree
Hide file tree
Showing 3 changed files with 213 additions and 7 deletions.
3 changes: 2 additions & 1 deletion homeassistant_cli/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import shlex
from typing import Any, Dict, Generator, List, Optional, Tuple, Union, cast
from typing import TextIO

from ruamel.yaml import YAML
from tabulate import tabulate
Expand All @@ -16,7 +17,7 @@
_LOGGING = logging.getLogger(__name__)


def to_attributes(entry: str) -> Dict[str, str]:
def to_attributes(entry: Union[str, TextIO]) -> Dict[str, str]:
"""Convert list of key=value pairs to dictionary."""
if not entry:
return {}
Expand Down
72 changes: 66 additions & 6 deletions homeassistant_cli/plugins/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import re as reg
import sys
from typing import Any, Dict, List, Pattern # noqa: F401
import json
import io

import click

Expand All @@ -11,6 +13,7 @@
from homeassistant_cli.config import Configuration
from homeassistant_cli.helper import format_output, to_attributes
import homeassistant_cli.remote as api
from homeassistant_cli.yaml import yaml

_LOGGING = logging.getLogger(__name__)

Expand Down Expand Up @@ -75,17 +78,77 @@ def list_cmd(ctx: Configuration, servicefilter):
)


def _argument_callback(ctx, param, value):
_LOGGING.debug("_argument_callback called, %s(%s)", param.name, value)

# We get called with value None
# for all the callbacks which aren't provided.
if value is None:
return

if 'data' in ctx.params and ctx.params['data'] is not None:
_LOGGING.error("You can only specify one type of the argument types!")
_LOGGING.debug(ctx.params)
ctx.exit()

if value == '-': # read from stdin
_LOGGING.debug("Loading value from stdin")
value = sys.stdin
elif value.startswith('@'): # read from file
_LOGGING.debug("Loading value from file: %s", value[1:])
value = open(value[1:], 'r')
else:
_LOGGING.debug("Using value as is: %s", value)

if param.name == 'arguments':
result = to_attributes(value)
elif param.name == 'json':
# We need to use different json calls to load from stream or string
if isinstance(value, str):
result = json.loads(value)
else:
result = json.load(value)
elif param.name == 'yaml':
result = yaml().load(value)
else:
_LOGGING.error("Parameter name is unknown: %s", param.name)
ctx.exit()

if isinstance(value, io.IOBase):
value.close()

ctx.params['data'] = result


@cli.command('call')
@click.argument(
'service',
required=True,
autocompletion=autocompletion.services, # type: ignore
)
@click.option(
'--arguments', help="Comma separated key/value pairs to use as arguments."
'--arguments', help="""Comma separated key/value pairs to use as arguments.
if string is -, the data is read from stdin, and if it starts with the letter @
the rest should be a filename from which the data is read""",
callback=_argument_callback,
expose_value=False
)
@click.option(
'--json', help="""Json string to use as arguments.
if string is -, the data is read from stdin, and if it starts with the letter @
the rest should be a filename from which the data is read""",
callback=_argument_callback,
expose_value=False
)
@click.option(
'--yaml', help="""Yaml string to use as arguments.
if string is -, the data is read from stdin, and if it starts with the letter @
the rest should be a filename from which the data is read""",
callback=_argument_callback,
expose_value=False
)
@pass_context
def call(ctx: Configuration, service, arguments):
def call(ctx: Configuration, service, data=None):
"""Call a service."""
ctx.auto_output('data')
_LOGGING.debug("service call <start>")
Expand All @@ -94,10 +157,7 @@ def call(ctx: Configuration, service, arguments):
_LOGGING.error("Service name not following <domain>.<service> format")
sys.exit(1)

_LOGGING.debug("Convert arguments %s to dict", arguments)
data = to_attributes(arguments)

_LOGGING.debug("service call_service")
_LOGGING.debug("calling %s.%s(%s)", parts[0], parts[1], data)

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

Expand Down
145 changes: 145 additions & 0 deletions tests/test_service.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Tests file for Home Assistant CLI (hass-cli)."""
import json
from unittest.mock import mock_open, patch

from click.testing import CliRunner
import requests_mock

import homeassistant_cli.autocompletion as autocompletion
import homeassistant_cli.cli as cli
from homeassistant_cli.config import Configuration
from homeassistant_cli.yaml import yaml, dumpyaml


def test_service_list(default_services) -> None:
Expand Down Expand Up @@ -91,3 +93,146 @@ def test_service_call(default_services) -> None:
assert result.exit_code == 0

assert post.call_count == 1


def test_service_call_with_arguments(default_services) -> None:
"""Test basic call of a service."""
with requests_mock.Mocker() as mock:

post = mock.post(
"http://localhost:8123/api/services/homeassistant/restart",
json={"result": "bogus"},
status_code=200,
)

runner = CliRunner()
result = runner.invoke(
cli.cli,
["--output=json", "service", "call", "homeassistant.restart",
"--arguments", "foo=bar,test=call"],
catch_exceptions=False,
)

assert result.exit_code == 0

assert post.call_count == 1

assert post.last_request.json() == {"foo": "bar", "test": "call"}


def test_service_call_with_json(default_services) -> None:
"""Test basic call of a service."""
with requests_mock.Mocker() as mock:

post = mock.post(
"http://localhost:8123/api/services/homeassistant/restart",
json={"result": "bogus"},
status_code=200,
)

data = {"foo": "bar", "test": True}

runner = CliRunner()
result = runner.invoke(
cli.cli,
["--output=json", "service", "call", "homeassistant.restart",
"--json", json.dumps(data)],
catch_exceptions=False,
)

assert result.exit_code == 0

assert post.call_count == 1

assert post.last_request.json() == data


def test_service_call_with_json_stdin(default_services) -> None:
"""Test basic call of a service."""
with requests_mock.Mocker() as mock:

post = mock.post(
"http://localhost:8123/api/services/homeassistant/restart",
json={"result": "bogus"},
status_code=200,
)

data = {
"foo": "bar",
"test": True,
}

runner = CliRunner()
result = runner.invoke(
cli.cli,
["--output=json", "service", "call", "homeassistant.restart",
"--json", "-"],
catch_exceptions=False,
input=json.dumps(data)
)

assert result.exit_code == 0

assert post.call_count == 1

assert post.last_request.json() == data


def test_service_call_with_yaml(default_services) -> None:
"""Test basic call of a service."""
with requests_mock.Mocker() as mock:

post = mock.post(
"http://localhost:8123/api/services/homeassistant/restart",
json={"result": "bogus"},
status_code=200,
)

runner = CliRunner()
result = runner.invoke(
cli.cli,
["--output=json", "service", "call", "homeassistant.restart",
"--yaml", "foo: bar"],
catch_exceptions=False,
)

assert result.exit_code == 0

assert post.call_count == 1

assert post.last_request.json() == {"foo": "bar"}


def test_service_call_with_yaml_file(default_services) -> None:
"""Test basic call of a service."""
with requests_mock.Mocker() as mock:

post = mock.post(
"http://localhost:8123/api/services/homeassistant/restart",
json={"result": "bogus"},
status_code=200,
)

data = {
"foo": "bar",
"test": True,
}

open_yaml_file = mock_open(read_data=dumpyaml(yaml(), data=data))

runner = CliRunner()
with patch('builtins.open', open_yaml_file) as mocked_open:
result = runner.invoke(
cli.cli,
["--output=json", "service", "call", "homeassistant.restart",
"--yaml", "@yaml_file.yml"],
catch_exceptions=False,
)

assert result.exit_code == 0

assert post.call_count == 1

mocked_open.assert_called_once_with("yaml_file.yml", "r")

assert post.last_request.json() == data

0 comments on commit 123c1d2

Please sign in to comment.