mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 19:23:34 +00:00
Add support for json output (#430)
* Add json support * Add tests * Check if test_json_output works on ci using py3.8+ * Add a proper note why py3.7 test for json_output are disabled
This commit is contained in:
parent
1212715dde
commit
016f4dfd19
84
kasa/cli.py
84
kasa/cli.py
@ -4,7 +4,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from functools import wraps
|
from functools import singledispatch, wraps
|
||||||
from pprint import pformat as pf
|
from pprint import pformat as pf
|
||||||
from typing import Any, Dict, cast
|
from typing import Any, Dict, cast
|
||||||
|
|
||||||
@ -63,10 +63,36 @@ class ExceptionHandlerGroup(click.Group):
|
|||||||
try:
|
try:
|
||||||
asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs))
|
asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
click.echo(f"Got error: {ex!r}")
|
echo(f"Got error: {ex!r}")
|
||||||
|
|
||||||
|
|
||||||
@click.group(invoke_without_command=True, cls=ExceptionHandlerGroup)
|
def json_formatter_cb(result, **kwargs):
|
||||||
|
"""Format and output the result as JSON, if requested."""
|
||||||
|
if not kwargs.get("json"):
|
||||||
|
return
|
||||||
|
|
||||||
|
@singledispatch
|
||||||
|
def to_serializable(val):
|
||||||
|
"""Regular obj-to-string for json serialization.
|
||||||
|
|
||||||
|
The singledispatch trick is from hynek: https://hynek.me/articles/serialization/
|
||||||
|
"""
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
@to_serializable.register(SmartDevice)
|
||||||
|
def _device_to_serializable(val: SmartDevice):
|
||||||
|
"""Serialize smart device data, just using the last update raw payload."""
|
||||||
|
return val.internal_state
|
||||||
|
|
||||||
|
json_content = json.dumps(result, indent=4, default=to_serializable)
|
||||||
|
print(json_content)
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(
|
||||||
|
invoke_without_command=True,
|
||||||
|
cls=ExceptionHandlerGroup,
|
||||||
|
result_callback=json_formatter_cb,
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--host",
|
"--host",
|
||||||
envvar="KASA_HOST",
|
envvar="KASA_HOST",
|
||||||
@ -94,9 +120,12 @@ class ExceptionHandlerGroup(click.Group):
|
|||||||
default=None,
|
default=None,
|
||||||
type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False),
|
type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False),
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--json", default=False, is_flag=True, help="Output raw device response as JSON."
|
||||||
|
)
|
||||||
@click.version_option(package_name="python-kasa")
|
@click.version_option(package_name="python-kasa")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
async def cli(ctx, host, alias, target, debug, type):
|
async def cli(ctx, host, alias, target, debug, type, json):
|
||||||
"""A tool for controlling TP-Link smart home devices.""" # noqa
|
"""A tool for controlling TP-Link smart home devices.""" # noqa
|
||||||
# no need to perform any checks if we are just displaying the help
|
# no need to perform any checks if we are just displaying the help
|
||||||
if sys.argv[-1] == "--help":
|
if sys.argv[-1] == "--help":
|
||||||
@ -104,6 +133,15 @@ async def cli(ctx, host, alias, target, debug, type):
|
|||||||
ctx.obj = SmartDevice(None)
|
ctx.obj = SmartDevice(None)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# If JSON output is requested, disable echo
|
||||||
|
if json:
|
||||||
|
global echo
|
||||||
|
|
||||||
|
def _nop_echo(*args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo = _nop_echo
|
||||||
|
|
||||||
logging_config: Dict[str, Any] = {
|
logging_config: Dict[str, Any] = {
|
||||||
"level": logging.DEBUG if debug > 0 else logging.INFO
|
"level": logging.DEBUG if debug > 0 else logging.INFO
|
||||||
}
|
}
|
||||||
@ -202,7 +240,7 @@ async def discover(ctx, timeout):
|
|||||||
await ctx.invoke(state)
|
await ctx.invoke(state)
|
||||||
echo()
|
echo()
|
||||||
|
|
||||||
await Discover.discover(
|
return await Discover.discover(
|
||||||
target=target, timeout=timeout, on_discovered=print_discovered
|
target=target, timeout=timeout, on_discovered=print_discovered
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -269,6 +307,8 @@ async def state(dev: SmartDevice):
|
|||||||
else:
|
else:
|
||||||
echo(f"\t[red]- {module}[/red]")
|
echo(f"\t[red]- {module}[/red]")
|
||||||
|
|
||||||
|
return dev.internal_state
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
@pass_dev
|
||||||
@ -293,6 +333,8 @@ async def alias(dev, new_alias, index):
|
|||||||
for plug in dev.children:
|
for plug in dev.children:
|
||||||
echo(f" * {plug.alias}")
|
echo(f" * {plug.alias}")
|
||||||
|
|
||||||
|
return dev.alias
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
@pass_dev
|
||||||
@ -328,8 +370,7 @@ async def emeter(dev: SmartDevice, year, month, erase):
|
|||||||
|
|
||||||
if erase:
|
if erase:
|
||||||
echo("Erasing emeter statistics..")
|
echo("Erasing emeter statistics..")
|
||||||
echo(await dev.erase_emeter_stats())
|
return await dev.erase_emeter_stats()
|
||||||
return
|
|
||||||
|
|
||||||
if year:
|
if year:
|
||||||
echo(f"== For year {year.year} ==")
|
echo(f"== For year {year.year} ==")
|
||||||
@ -351,12 +392,14 @@ async def emeter(dev: SmartDevice, year, month, erase):
|
|||||||
echo("Today: %s kWh" % dev.emeter_today)
|
echo("Today: %s kWh" % dev.emeter_today)
|
||||||
echo("This month: %s kWh" % dev.emeter_this_month)
|
echo("This month: %s kWh" % dev.emeter_this_month)
|
||||||
|
|
||||||
return
|
return emeter_status
|
||||||
|
|
||||||
# output any detailed usage data
|
# output any detailed usage data
|
||||||
for index, usage in usage_data.items():
|
for index, usage in usage_data.items():
|
||||||
echo(f"{index}, {usage}")
|
echo(f"{index}, {usage}")
|
||||||
|
|
||||||
|
return usage_data
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
@pass_dev
|
||||||
@ -373,8 +416,7 @@ async def usage(dev: SmartDevice, year, month, erase):
|
|||||||
|
|
||||||
if erase:
|
if erase:
|
||||||
echo("Erasing usage statistics..")
|
echo("Erasing usage statistics..")
|
||||||
echo(await usage.erase_stats())
|
return await usage.erase_stats()
|
||||||
return
|
|
||||||
|
|
||||||
if year:
|
if year:
|
||||||
echo(f"== For year {year.year} ==")
|
echo(f"== For year {year.year} ==")
|
||||||
@ -389,12 +431,14 @@ async def usage(dev: SmartDevice, year, month, erase):
|
|||||||
echo("Today: %s minutes" % usage.usage_today)
|
echo("Today: %s minutes" % usage.usage_today)
|
||||||
echo("This month: %s minutes" % usage.usage_this_month)
|
echo("This month: %s minutes" % usage.usage_this_month)
|
||||||
|
|
||||||
return
|
return usage
|
||||||
|
|
||||||
# output any detailed usage data
|
# output any detailed usage data
|
||||||
for index, usage in usage_data.items():
|
for index, usage in usage_data.items():
|
||||||
echo(f"{index}, {usage}")
|
echo(f"{index}, {usage}")
|
||||||
|
|
||||||
|
return usage_data
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
|
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
|
||||||
@ -408,6 +452,7 @@ async def brightness(dev: SmartBulb, brightness: int, transition: int):
|
|||||||
|
|
||||||
if brightness is None:
|
if brightness is None:
|
||||||
echo(f"Brightness: {dev.brightness}")
|
echo(f"Brightness: {dev.brightness}")
|
||||||
|
return dev.brightness
|
||||||
else:
|
else:
|
||||||
echo(f"Setting brightness to {brightness}")
|
echo(f"Setting brightness to {brightness}")
|
||||||
return await dev.set_brightness(brightness, transition=transition)
|
return await dev.set_brightness(brightness, transition=transition)
|
||||||
@ -435,6 +480,7 @@ async def temperature(dev: SmartBulb, temperature: int, transition: int):
|
|||||||
"Temperature range unknown, please open a github issue"
|
"Temperature range unknown, please open a github issue"
|
||||||
f" or a pull request for model '{dev.model}'"
|
f" or a pull request for model '{dev.model}'"
|
||||||
)
|
)
|
||||||
|
return dev.valid_temperature_range
|
||||||
else:
|
else:
|
||||||
echo(f"Setting color temperature to {temperature}")
|
echo(f"Setting color temperature to {temperature}")
|
||||||
return await dev.set_color_temp(temperature, transition=transition)
|
return await dev.set_color_temp(temperature, transition=transition)
|
||||||
@ -476,6 +522,7 @@ async def hsv(dev, ctx, h, s, v, transition):
|
|||||||
|
|
||||||
if h is None or s is None or v is None:
|
if h is None or s is None or v is None:
|
||||||
echo(f"Current HSV: {dev.hsv}")
|
echo(f"Current HSV: {dev.hsv}")
|
||||||
|
return dev.hsv
|
||||||
elif s is None or v is None:
|
elif s is None or v is None:
|
||||||
raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx)
|
raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx)
|
||||||
else:
|
else:
|
||||||
@ -493,6 +540,7 @@ async def led(dev, state):
|
|||||||
return await dev.set_led(state)
|
return await dev.set_led(state)
|
||||||
else:
|
else:
|
||||||
echo(f"LED state: {dev.led}")
|
echo(f"LED state: {dev.led}")
|
||||||
|
return dev.led
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@ -574,6 +622,8 @@ def _schedule_list(dev, type):
|
|||||||
else:
|
else:
|
||||||
echo(f"No rules of type {type}")
|
echo(f"No rules of type {type}")
|
||||||
|
|
||||||
|
return sched.rules
|
||||||
|
|
||||||
|
|
||||||
@schedule.command(name="delete")
|
@schedule.command(name="delete")
|
||||||
@pass_dev
|
@pass_dev
|
||||||
@ -584,7 +634,7 @@ async def delete_rule(dev, id):
|
|||||||
rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None)
|
rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None)
|
||||||
if rule_to_delete:
|
if rule_to_delete:
|
||||||
echo(f"Deleting rule id {id}")
|
echo(f"Deleting rule id {id}")
|
||||||
await schedule.delete_rule(rule_to_delete)
|
return await schedule.delete_rule(rule_to_delete)
|
||||||
else:
|
else:
|
||||||
echo(f"No rule with id {id} was found")
|
echo(f"No rule with id {id} was found")
|
||||||
|
|
||||||
@ -606,7 +656,9 @@ def presets_list(dev: SmartBulb):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for preset in dev.presets:
|
for preset in dev.presets:
|
||||||
print(preset)
|
echo(preset)
|
||||||
|
|
||||||
|
return dev.presets
|
||||||
|
|
||||||
|
|
||||||
@presets.command(name="modify")
|
@presets.command(name="modify")
|
||||||
@ -638,7 +690,7 @@ async def presets_modify(
|
|||||||
|
|
||||||
echo(f"Going to save preset: {preset}")
|
echo(f"Going to save preset: {preset}")
|
||||||
|
|
||||||
await dev.save_preset(preset)
|
return await dev.save_preset(preset)
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@ -653,7 +705,7 @@ async def turn_on_behavior(dev: SmartBulb, type, last, preset):
|
|||||||
|
|
||||||
# Return if we are not setting the value
|
# Return if we are not setting the value
|
||||||
if not type and not last and not preset:
|
if not type and not last and not preset:
|
||||||
return
|
return settings
|
||||||
|
|
||||||
# If we are setting the value, the type has to be specified
|
# If we are setting the value, the type has to be specified
|
||||||
if (last or preset) and type is None:
|
if (last or preset) and type is None:
|
||||||
@ -669,7 +721,7 @@ async def turn_on_behavior(dev: SmartBulb, type, last, preset):
|
|||||||
echo(f"Going to set {type} to preset {preset}")
|
echo(f"Going to set {type} to preset {preset}")
|
||||||
behavior.preset = preset
|
behavior.preset = preset
|
||||||
|
|
||||||
await dev.set_turn_on_behavior(settings)
|
return await dev.set_turn_on_behavior(settings)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
@ -1,19 +1,11 @@
|
|||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from asyncclick.testing import CliRunner
|
from asyncclick.testing import CliRunner
|
||||||
|
|
||||||
from kasa import SmartDevice
|
from kasa import SmartDevice
|
||||||
from kasa.cli import (
|
from kasa.cli import alias, brightness, cli, emeter, raw_command, state, sysinfo
|
||||||
TYPE_TO_CLASS,
|
|
||||||
alias,
|
|
||||||
brightness,
|
|
||||||
cli,
|
|
||||||
emeter,
|
|
||||||
raw_command,
|
|
||||||
state,
|
|
||||||
sysinfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .conftest import handle_turn_on, turn_on
|
from .conftest import handle_turn_on, turn_on
|
||||||
|
|
||||||
@ -111,25 +103,13 @@ async def test_brightness(dev):
|
|||||||
assert "Brightness: 12" in res.output
|
assert "Brightness: 12" in res.output
|
||||||
|
|
||||||
|
|
||||||
async def test_temperature(dev):
|
# Invoke fails when run on py3.7 with the following error:
|
||||||
pass
|
# E + where 1 = <Result TypeError("object list can't be used in 'await' expression")>.exit_code
|
||||||
|
@pytest.mark.skipif(sys.version_info < (3, 8), reason="fails on python3.7")
|
||||||
|
async def test_json_output(dev: SmartDevice, mocker):
|
||||||
async def test_hsv(dev):
|
"""Test that the json output produces correct output."""
|
||||||
pass
|
mocker.patch("kasa.Discover.discover", return_value=[dev])
|
||||||
|
runner = CliRunner()
|
||||||
|
res = await runner.invoke(cli, ["--json", "state"], obj=dev)
|
||||||
async def test_led(dev):
|
assert res.exit_code == 0
|
||||||
pass
|
assert json.loads(res.output) == dev.internal_state
|
||||||
|
|
||||||
|
|
||||||
async def test_on(dev):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def test_off(dev):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def test_reboot(dev):
|
|
||||||
pass
|
|
||||||
|
Loading…
Reference in New Issue
Block a user