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 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__":

View File

@ -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