Update documentation structure and start migrating to markdown (#934)

Starts structuring the documentation library usage into Tutorials, Guides, Explanations and Reference.
Continues migrating new docs from rst to markdown.
Extends the test framework discovery mocks to allow easy writing and testing of code examples.
This commit is contained in:
Steven B
2024-06-03 12:14:10 +03:00
committed by GitHub
parent 767156421b
commit 6616d68d42
31 changed files with 619 additions and 324 deletions

View File

@@ -396,6 +396,13 @@ async def get_device_for_fixture_protocol(fixture, protocol):
return await get_device_for_fixture(fixture_info)
def get_fixture_info(fixture, protocol):
finfo = FixtureInfo(name=fixture, protocol=protocol, data={})
for fixture_info in FIXTURE_DATA:
if finfo == fixture_info:
return fixture_info
@pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator)
async def dev(request) -> AsyncGenerator[Device, None]:
"""Device fixture.

View File

@@ -44,9 +44,14 @@ UNSUPPORTED_DEVICES = {
}
def parametrize_discovery(desc, *, data_root_filter, protocol_filter=None):
def parametrize_discovery(
desc, *, data_root_filter=None, protocol_filter=None, model_filter=None
):
filtered_fixtures = filter_fixtures(
desc, data_root_filter=data_root_filter, protocol_filter=protocol_filter
desc,
data_root_filter=data_root_filter,
protocol_filter=protocol_filter,
model_filter=model_filter,
)
return pytest.mark.parametrize(
"discovery_mock",
@@ -65,10 +70,14 @@ new_discovery = parametrize_discovery(
params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}),
ids=idgenerator,
)
def discovery_mock(request, mocker):
async def discovery_mock(request, mocker):
"""Mock discovery and patch protocol queries to use Fake protocols."""
fixture_info: FixtureInfo = request.param
fixture_data = fixture_info.data
yield patch_discovery({"127.0.0.123": fixture_info}, mocker)
def create_discovery_mock(ip: str, fixture_data: dict):
"""Mock discovery and patch protocol queries to use Fake protocols."""
@dataclass
class _DiscoveryMock:
@@ -79,6 +88,7 @@ def discovery_mock(request, mocker):
query_data: dict
device_type: str
encrypt_type: str
_datagram: bytes
login_version: int | None = None
port_override: int | None = None
@@ -94,13 +104,14 @@ def discovery_mock(request, mocker):
+ json_dumps(discovery_data).encode()
)
dm = _DiscoveryMock(
"127.0.0.123",
ip,
80,
20002,
discovery_data,
fixture_data,
device_type,
encrypt_type,
datagram,
login_version,
)
else:
@@ -111,45 +122,87 @@ def discovery_mock(request, mocker):
login_version = None
datagram = XorEncryption.encrypt(json_dumps(discovery_data))[4:]
dm = _DiscoveryMock(
"127.0.0.123",
ip,
9999,
9999,
discovery_data,
fixture_data,
device_type,
encrypt_type,
datagram,
login_version,
)
async def mock_discover(self):
port = (
dm.port_override
if dm.port_override and dm.discovery_port != 20002
else dm.discovery_port
)
self.datagram_received(
datagram,
(dm.ip, port),
)
return dm
def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker):
"""Mock discovery and patch protocol queries to use Fake protocols."""
discovery_mocks = {
ip: create_discovery_mock(ip, fixture_info.data)
for ip, fixture_info in fixture_infos.items()
}
protos = {
ip: FakeSmartProtocol(fixture_info.data, fixture_info.name)
if "SMART" in fixture_info.protocol
else FakeIotProtocol(fixture_info.data, fixture_info.name)
for ip, fixture_info in fixture_infos.items()
}
first_ip = list(fixture_infos.keys())[0]
first_host = None
async def mock_discover(self):
"""Call datagram_received for all mock fixtures.
Handles test cases modifying the ip and hostname of the first fixture
for discover_single testing.
"""
for ip, dm in discovery_mocks.items():
first_ip = list(discovery_mocks.values())[0].ip
fixture_info = fixture_infos[ip]
# Ip of first fixture could have been modified by a test
if dm.ip == first_ip:
# hostname could have been used
host = first_host if first_host else first_ip
else:
host = dm.ip
# update the protos for any host testing or the test overriding the first ip
protos[host] = (
FakeSmartProtocol(fixture_info.data, fixture_info.name)
if "SMART" in fixture_info.protocol
else FakeIotProtocol(fixture_info.data, fixture_info.name)
)
port = (
dm.port_override
if dm.port_override and dm.discovery_port != 20002
else dm.discovery_port
)
self.datagram_received(
dm._datagram,
(dm.ip, port),
)
async def _query(self, request, retry_count: int = 3):
return await protos[self._host].query(request)
def _getaddrinfo(host, *_, **__):
nonlocal first_host, first_ip
first_host = host # Store the hostname used by discover single
first_ip = list(discovery_mocks.values())[
0
].ip # ip could have been overridden in test
return [(None, None, None, None, (first_ip, 0))]
mocker.patch("kasa.IotProtocol.query", _query)
mocker.patch("kasa.SmartProtocol.query", _query)
mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover)
mocker.patch(
"socket.getaddrinfo",
side_effect=lambda *_, **__: [(None, None, None, None, (dm.ip, 0))],
# side_effect=lambda *_, **__: [(None, None, None, None, (first_ip, 0))],
side_effect=_getaddrinfo,
)
if "SMART" in fixture_info.protocol:
proto = FakeSmartProtocol(fixture_data, fixture_info.name)
else:
proto = FakeIotProtocol(fixture_data)
async def _query(request, retry_count: int = 3):
return await proto.query(request)
mocker.patch("kasa.IotProtocol.query", side_effect=_query)
mocker.patch("kasa.SmartProtocol.query", side_effect=_query)
yield dm
# Only return the first discovery mock to be used for testing discover single
return discovery_mocks[first_ip]
@pytest.fixture(

View File

@@ -3,7 +3,7 @@ import logging
from ..deviceconfig import DeviceConfig
from ..iotprotocol import IotProtocol
from ..xortransport import XorTransport
from ..protocol import BaseTransport
_LOGGER = logging.getLogger(__name__)
@@ -178,17 +178,26 @@ MOTION_MODULE = {
class FakeIotProtocol(IotProtocol):
def __init__(self, info):
def __init__(self, info, fixture_name=None):
super().__init__(
transport=XorTransport(
config=DeviceConfig("127.0.0.123"),
)
transport=FakeIotTransport(info, fixture_name),
)
async def query(self, request, retry_count: int = 3):
"""Implement query here so tests can still patch IotProtocol.query."""
resp_dict = await self._query(request, retry_count)
return resp_dict
class FakeIotTransport(BaseTransport):
def __init__(self, info, fixture_name=None):
super().__init__(config=DeviceConfig("127.0.0.123"))
info = copy.deepcopy(info)
self.discovery_data = info
self.fixture_name = fixture_name
self.writer = None
self.reader = None
proto = copy.deepcopy(FakeIotProtocol.baseproto)
proto = copy.deepcopy(FakeIotTransport.baseproto)
for target in info:
# print("target %s" % target)
@@ -220,6 +229,14 @@ class FakeIotProtocol(IotProtocol):
self.proto = proto
@property
def default_port(self) -> int:
return 9999
@property
def credentials_hash(self) -> str:
return ""
def set_alias(self, x, child_ids=None):
if child_ids is None:
child_ids = []
@@ -367,7 +384,7 @@ class FakeIotProtocol(IotProtocol):
"smartlife.iot.common.cloud": CLOUD_MODULE,
}
async def query(self, request, port=9999):
async def send(self, request, port=9999):
proto = self.proto
# collect child ids from context
@@ -414,3 +431,9 @@ class FakeIotProtocol(IotProtocol):
response.update(get_response_for_module(target))
return copy.deepcopy(response)
async def close(self) -> None:
pass
async def reset(self) -> None:
pass

View File

@@ -11,7 +11,7 @@
"system": {
"get_sysinfo": {
"active_mode": "schedule",
"alias": "Kitchen",
"alias": "Bedroom Lamp Plug",
"dev_name": "Wi-Fi Smart Plug With Energy Monitoring",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@@ -28,7 +28,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Living room left dimmer",
"alias": "Living Room Dimmer Switch",
"brightness": 25,
"dev_name": "Smart Wi-Fi Dimmer",
"deviceId": "000000000000000000000000000000000000000",

View File

@@ -17,7 +17,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Living Room Lights",
"alias": "Living Room Dimmer Switch",
"brightness": 100,
"dev_name": "Wi-Fi Smart Dimmer",
"deviceId": "0000000000000000000000000000000000000000",

View File

@@ -23,7 +23,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "KL430 pantry lightstrip",
"alias": "Bedroom Lightstrip",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@@ -1,7 +1,7 @@
{
"system": {
"get_sysinfo": {
"alias": "TP-LINK_Power Strip_CF69",
"alias": "Bedroom Power Strip",
"child_num": 3,
"children": [
{

View File

@@ -175,7 +175,7 @@
"longitude": 0,
"mac": "5C-E9-31-00-00-00",
"model": "L530",
"nickname": "TGl2aW5nIFJvb20=",
"nickname": "TGl2aW5nIFJvb20gQnVsYg==",
"oem_id": "00000000000000000000000000000000",
"overheated": false,
"region": "Europe/Berlin",

View File

@@ -107,7 +107,6 @@ async def test_type_unknown():
@pytest.mark.parametrize("custom_port", [123, None])
# @pytest.mark.parametrize("discovery_mock", [("127.0.0.1",123), ("127.0.0.1",None)], indirect=True)
async def test_discover_single(discovery_mock, custom_port, mocker):
"""Make sure that discover_single returns an initialized SmartDevice instance."""
host = "127.0.0.1"
@@ -115,7 +114,8 @@ async def test_discover_single(discovery_mock, custom_port, mocker):
discovery_mock.port_override = custom_port
device_class = Discover._get_device_class(discovery_mock.discovery_data)
update_mock = mocker.patch.object(device_class, "update")
# discovery_mock patches protocol query methods so use spy here.
update_mock = mocker.spy(device_class, "update")
x = await Discover.discover_single(
host, port=custom_port, credentials=Credentials()
@@ -123,6 +123,7 @@ async def test_discover_single(discovery_mock, custom_port, mocker):
assert issubclass(x.__class__, Device)
assert x._discovery_info is not None
assert x.port == custom_port or x.port == discovery_mock.default_port
# Make sure discovery does not call update()
assert update_mock.call_count == 0
if discovery_mock.default_port == 80:
assert x.alias is None

View File

@@ -3,8 +3,11 @@ import asyncio
import pytest
import xdoctest
from kasa import Discover
from kasa.tests.conftest import get_device_for_fixture_protocol
from kasa.tests.conftest import (
get_device_for_fixture_protocol,
get_fixture_info,
patch_discovery,
)
def test_bulb_examples(mocker):
@@ -62,34 +65,39 @@ def test_lightstrip_examples(mocker):
assert not res["failed"]
def test_discovery_examples(mocker):
def test_discovery_examples(readmes_mock):
"""Test discovery examples."""
p = asyncio.run(get_device_for_fixture_protocol("KP303(UK)_1.0_1.0.3.json", "IOT"))
mocker.patch("kasa.discover.Discover.discover", return_value=[p])
res = xdoctest.doctest_module("kasa.discover", "all")
assert res["n_passed"] > 0
assert not res["failed"]
def test_tutorial_examples(mocker, top_level_await):
def test_deviceconfig_examples(readmes_mock):
"""Test discovery examples."""
a = asyncio.run(
get_device_for_fixture_protocol("L530E(EU)_3.0_1.1.6.json", "SMART")
)
b = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT"))
a.host = "127.0.0.1"
b.host = "127.0.0.2"
# Note autospec does not work for staticmethods in python < 3.12
# https://github.com/python/cpython/issues/102978
mocker.patch(
"kasa.discover.Discover.discover_single", return_value=a, autospec=True
)
mocker.patch.object(Discover, "discover", return_value=[a, b], autospec=True)
res = xdoctest.doctest_module("docs/tutorial.py", "all")
res = xdoctest.doctest_module("kasa.deviceconfig", "all")
assert res["n_passed"] > 0
assert not res["failed"]
def test_tutorial_examples(readmes_mock):
"""Test discovery examples."""
res = xdoctest.doctest_module("docs/tutorial.py", "all")
assert res["n_passed"] > 0
assert not res["failed"]
@pytest.fixture
async def readmes_mock(mocker, top_level_await):
fixture_infos = {
"127.0.0.1": get_fixture_info("KP303(UK)_1.0_1.0.3.json", "IOT"), # Strip
"127.0.0.2": get_fixture_info("HS110(EU)_1.0_1.2.5.json", "IOT"), # Plug
"127.0.0.3": get_fixture_info("L530E(EU)_3.0_1.1.6.json", "SMART"), # Bulb
"127.0.0.4": get_fixture_info("KL430(US)_1.0_1.0.10.json", "IOT"), # Lightstrip
"127.0.0.5": get_fixture_info("HS220(US)_1.0_1.5.7.json", "IOT"), # Dimmer
}
yield patch_discovery(fixture_infos, mocker)
@pytest.fixture
def top_level_await(mocker):
"""Fixture to enable top level awaits in doctests.
@@ -99,19 +107,26 @@ def top_level_await(mocker):
"""
import ast
from inspect import CO_COROUTINE
from types import CodeType
orig_exec = exec
orig_eval = eval
orig_compile = compile
def patch_exec(source, globals=None, locals=None, /, **kwargs):
if source.co_flags & CO_COROUTINE == CO_COROUTINE:
if (
isinstance(source, CodeType)
and source.co_flags & CO_COROUTINE == CO_COROUTINE
):
asyncio.run(orig_eval(source, globals, locals))
else:
orig_exec(source, globals, locals, **kwargs)
def patch_eval(source, globals=None, locals=None, /, **kwargs):
if source.co_flags & CO_COROUTINE == CO_COROUTINE:
if (
isinstance(source, CodeType)
and source.co_flags & CO_COROUTINE == CO_COROUTINE
):
return asyncio.run(orig_eval(source, globals, locals, **kwargs))
else:
return orig_eval(source, globals, locals, **kwargs)