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:
Teemu R 2023-02-18 21:41:08 +01:00 committed by GitHub
parent 1212715dde
commit 016f4dfd19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 80 additions and 48 deletions

View File

@ -4,7 +4,7 @@ import json
import logging
import re
import sys
from functools import wraps
from functools import singledispatch, wraps
from pprint import pformat as pf
from typing import Any, Dict, cast
@ -63,10 +63,36 @@ class ExceptionHandlerGroup(click.Group):
try:
asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs))
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(
"--host",
envvar="KASA_HOST",
@ -94,9 +120,12 @@ class ExceptionHandlerGroup(click.Group):
default=None,
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.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
# no need to perform any checks if we are just displaying the help
if sys.argv[-1] == "--help":
@ -104,6 +133,15 @@ async def cli(ctx, host, alias, target, debug, type):
ctx.obj = SmartDevice(None)
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] = {
"level": logging.DEBUG if debug > 0 else logging.INFO
}
@ -202,7 +240,7 @@ async def discover(ctx, timeout):
await ctx.invoke(state)
echo()
await Discover.discover(
return await Discover.discover(
target=target, timeout=timeout, on_discovered=print_discovered
)
@ -269,6 +307,8 @@ async def state(dev: SmartDevice):
else:
echo(f"\t[red]- {module}[/red]")
return dev.internal_state
@cli.command()
@pass_dev
@ -293,6 +333,8 @@ async def alias(dev, new_alias, index):
for plug in dev.children:
echo(f" * {plug.alias}")
return dev.alias
@cli.command()
@pass_dev
@ -328,8 +370,7 @@ async def emeter(dev: SmartDevice, year, month, erase):
if erase:
echo("Erasing emeter statistics..")
echo(await dev.erase_emeter_stats())
return
return await dev.erase_emeter_stats()
if 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("This month: %s kWh" % dev.emeter_this_month)
return
return emeter_status
# output any detailed usage data
for index, usage in usage_data.items():
echo(f"{index}, {usage}")
return usage_data
@cli.command()
@pass_dev
@ -373,8 +416,7 @@ async def usage(dev: SmartDevice, year, month, erase):
if erase:
echo("Erasing usage statistics..")
echo(await usage.erase_stats())
return
return await usage.erase_stats()
if 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("This month: %s minutes" % usage.usage_this_month)
return
return usage
# output any detailed usage data
for index, usage in usage_data.items():
echo(f"{index}, {usage}")
return usage_data
@cli.command()
@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:
echo(f"Brightness: {dev.brightness}")
return dev.brightness
else:
echo(f"Setting brightness to {brightness}")
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"
f" or a pull request for model '{dev.model}'"
)
return dev.valid_temperature_range
else:
echo(f"Setting color temperature to {temperature}")
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:
echo(f"Current HSV: {dev.hsv}")
return dev.hsv
elif s is None or v is None:
raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx)
else:
@ -493,6 +540,7 @@ async def led(dev, state):
return await dev.set_led(state)
else:
echo(f"LED state: {dev.led}")
return dev.led
@cli.command()
@ -574,6 +622,8 @@ def _schedule_list(dev, type):
else:
echo(f"No rules of type {type}")
return sched.rules
@schedule.command(name="delete")
@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)
if rule_to_delete:
echo(f"Deleting rule id {id}")
await schedule.delete_rule(rule_to_delete)
return await schedule.delete_rule(rule_to_delete)
else:
echo(f"No rule with id {id} was found")
@ -606,7 +656,9 @@ def presets_list(dev: SmartBulb):
return
for preset in dev.presets:
print(preset)
echo(preset)
return dev.presets
@presets.command(name="modify")
@ -638,7 +690,7 @@ async def presets_modify(
echo(f"Going to save preset: {preset}")
await dev.save_preset(preset)
return await dev.save_preset(preset)
@cli.command()
@ -653,7 +705,7 @@ async def turn_on_behavior(dev: SmartBulb, type, last, preset):
# Return if we are not setting the value
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 (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}")
behavior.preset = preset
await dev.set_turn_on_behavior(settings)
return await dev.set_turn_on_behavior(settings)
if __name__ == "__main__":

View File

@ -1,19 +1,11 @@
import json
import sys
import pytest
from asyncclick.testing import CliRunner
from kasa import SmartDevice
from kasa.cli import (
TYPE_TO_CLASS,
alias,
brightness,
cli,
emeter,
raw_command,
state,
sysinfo,
)
from kasa.cli import alias, brightness, cli, emeter, raw_command, state, sysinfo
from .conftest import handle_turn_on, turn_on
@ -111,25 +103,13 @@ async def test_brightness(dev):
assert "Brightness: 12" in res.output
async def test_temperature(dev):
pass
async def test_hsv(dev):
pass
async def test_led(dev):
pass
async def test_on(dev):
pass
async def test_off(dev):
pass
async def test_reboot(dev):
pass
# Invoke fails when run on py3.7 with the following error:
# 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):
"""Test that the json output produces correct output."""
mocker.patch("kasa.Discover.discover", return_value=[dev])
runner = CliRunner()
res = await runner.invoke(cli, ["--json", "state"], obj=dev)
assert res.exit_code == 0
assert json.loads(res.output) == dev.internal_state