Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def get_command(
except ImportError:
# todo: print out issue of loading plugins?
return None
return cast(Union[Group, Command], mod.cli) # type: ignore
return cast(Union[Group, Command], mod.cli)


def _default_token() -> Optional[str]:
Expand Down
48 changes: 47 additions & 1 deletion homeassistant_cli/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import logging
import shlex
from typing import Any, Dict, Generator, List, Optional, Tuple, Union, cast
from typing import TextIO
import sys
import io

from ruamel.yaml import YAML
from tabulate import tabulate
Expand All @@ -16,7 +19,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 Expand Up @@ -183,3 +186,46 @@ def debug_requests() -> Generator:
debug_requests_on()
yield
debug_requests_off()


def argument_callback(ctx, param, value):
"""Helper to parse json, yaml and key-value arguments"""
_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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined name 'sys'

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.yaml().load(value)
else:
_LOGGING.error("Parameter name is unknown: %s", param.name)
ctx.exit()

if isinstance(value, io.IOBase):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined name 'io'

value.close()

ctx.params['data'] = result
52 changes: 31 additions & 21 deletions homeassistant_cli/plugins/raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from homeassistant_cli.config import Configuration
from homeassistant_cli.helper import format_output
import homeassistant_cli.remote as api
from homeassistant_cli.helper import argument_callback

_LOGGING = logging.getLogger(__name__)

Expand Down Expand Up @@ -56,16 +57,23 @@ def get(ctx: Configuration, method):
@click.argument(
'method', shell_complete=autocompletion.api_methods # type: ignore
)
@click.option('--json')
@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 post(ctx: Configuration, method, json):
def post(ctx: Configuration, method, data={}): # noqa: D301
"""Do a POST request against api/<method>."""
if json:
data = json_.loads(
json if json != "-" else click.get_text_stream('stdin').read()
)
else:
data = {}

response = api.restapi(ctx, 'post', method, data)

Expand All @@ -76,24 +84,26 @@ def post(ctx: Configuration, method, json):
@click.argument(
'wstype', shell_complete=autocompletion.wsapi_methods # type: ignore
)
@click.option('--json')
@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 websocket(ctx: Configuration, wstype, json): # noqa: D301
def websocket(ctx: Configuration, wstype, data={}): # noqa: D301
"""Send a websocket request against /api/websocket.

WSTYPE is name of websocket methods.

\b
--json is dictionary to pass in addition to the type.
Example: --json='{ "area_id":"2c8bf93c8082492f99c989896962f207" }'
"""
if json:
data = json_.loads(
json if json != "-" else click.get_text_stream('stdin').read()
)
else:
data = {}

frame = {'type': wstype}
frame = {**frame, **data} # merging data into frame

Expand Down
30 changes: 23 additions & 7 deletions homeassistant_cli/plugins/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
import homeassistant_cli.autocompletion as autocompletion
from homeassistant_cli.cli import pass_context
from homeassistant_cli.config import Configuration
from homeassistant_cli.helper import format_output, to_attributes
from homeassistant_cli.helper import format_output
import homeassistant_cli.remote as api
from homeassistant_cli.helper import argument_callback

_LOGGING = logging.getLogger(__name__)

Expand Down Expand Up @@ -83,10 +84,28 @@ def list_cmd(ctx: Configuration, servicefilter):
shell_complete=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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined name '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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined name '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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined name '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 @@ -95,10 +114,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