From 123c1d20ea240d98d00305ed4a75aa38ee3e41ca Mon Sep 17 00:00:00 2001 From: Anton Lundin Date: Tue, 14 Jan 2020 10:45:09 +0100 Subject: [PATCH] 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 --- homeassistant_cli/helper.py | 3 +- homeassistant_cli/plugins/service.py | 72 +++++++++++-- tests/test_service.py | 145 +++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 7 deletions(-) diff --git a/homeassistant_cli/helper.py b/homeassistant_cli/helper.py index 16e66ee..4ef3235 100644 --- a/homeassistant_cli/helper.py +++ b/homeassistant_cli/helper.py @@ -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 @@ -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 {} diff --git a/homeassistant_cli/plugins/service.py b/homeassistant_cli/plugins/service.py index 5ecfaee..504402f 100644 --- a/homeassistant_cli/plugins/service.py +++ b/homeassistant_cli/plugins/service.py @@ -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 @@ -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__) @@ -75,6 +78,48 @@ 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', @@ -82,10 +127,28 @@ def list_cmd(ctx: Configuration, servicefilter): 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 ") @@ -94,10 +157,7 @@ def call(ctx: Configuration, service, arguments): _LOGGING.error("Service name not following . 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) diff --git a/tests/test_service.py b/tests/test_service.py index f180c09..447afb2 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,5 +1,6 @@ """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 @@ -7,6 +8,7 @@ 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: @@ -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