Use ruff and ruff format (#534)

Replaces the previously used linting and code formatting tools with ruff.
This commit is contained in:
Teemu R 2023-10-29 23:15:42 +01:00 committed by GitHub
parent 0061668c9f
commit c431dbb832
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 220 additions and 174 deletions

View File

@ -1,8 +0,0 @@
[flake8]
exclude = .git,.tox,__pycache__,kasa/tests/newfakes.py,kasa/tests/test_fixtures.py
max-line-length = 88
per-file-ignores =
kasa/tests/*.py:D100,D101,D102,D103,D104,F401
docs/source/conf.py:D100,D103
ignore = D105, D107, E203, E501, W503
max-complexity = 18

View File

@ -26,18 +26,9 @@ jobs:
run: | run: |
python -m pip install --upgrade pip poetry python -m pip install --upgrade pip poetry
poetry install poetry install
- name: "Run pyupgrade" - name: "Linting and code formating (ruff)"
run: | run: |
poetry run pre-commit run pyupgrade --all-files poetry run pre-commit run ruff --all-files
- name: "Code formating (black)"
run: |
poetry run pre-commit run black --all-files
- name: "Code formating (flake8)"
run: |
poetry run pre-commit run flake8 --all-files
- name: "Order of imports (isort)"
run: |
poetry run pre-commit run isort --all-files
- name: "Typing checks (mypy)" - name: "Typing checks (mypy)"
run: | run: |
poetry run pre-commit run mypy --all-files poetry run pre-commit run mypy --all-files

View File

@ -9,28 +9,12 @@ repos:
- id: debug-statements - id: debug-statements
- id: check-ast - id: check-ast
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v3.4.0 rev: v0.1.3
hooks: hooks:
- id: pyupgrade - id: ruff
args: ['--py38-plus'] args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/python/black
rev: 23.3.0
hooks:
- id: black
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
hooks:
- id: flake8
additional_dependencies: [flake8-docstrings]
- repo: https://github.com/pre-commit/mirrors-isort
rev: v5.10.1
hooks:
- id: isort
additional_dependencies: [toml]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.3.0 rev: v1.3.0

View File

@ -1,7 +1,8 @@
"""Script that checks if README.md is missing devices that have fixtures.""" """Script that checks if README.md is missing devices that have fixtures."""
from kasa.tests.conftest import ALL_DEVICES, BULBS, DIMMERS, LIGHT_STRIPS, PLUGS, STRIPS from kasa.tests.conftest import ALL_DEVICES, BULBS, DIMMERS, LIGHT_STRIPS, PLUGS, STRIPS
readme = open("README.md").read() with open("README.md") as f:
readme = f.read()
typemap = { typemap = {
"light strips": LIGHT_STRIPS, "light strips": LIGHT_STRIPS,

View File

@ -1,7 +1,8 @@
"""This script generates devinfo files for the test suite. """This script generates devinfo files for the test suite.
If you have new, yet unsupported device or a device with no devinfo file under kasa/tests/fixtures, If you have new, yet unsupported device or a device with no devinfo file under
feel free to run this script and create a PR to add the file to the repository. kasa/tests/fixtures, feel free to run this script and create a PR to add the file
to the repository.
Executing this script will several modules and methods one by one, Executing this script will several modules and methods one by one,
and finally execute a query to query all of them at once. and finally execute a query to query all of them at once.
@ -84,13 +85,13 @@ def cli(host, debug):
for test_call in items: for test_call in items:
async def _run_query(): async def _run_query(test_call):
protocol = TPLinkSmartHomeProtocol(host) protocol = TPLinkSmartHomeProtocol(host)
return await protocol.query({test_call.module: {test_call.method: None}}) return await protocol.query({test_call.module: {test_call.method: None}})
try: try:
click.echo(f"Testing {test_call}..", nl=False) click.echo(f"Testing {test_call}..", nl=False)
info = asyncio.run(_run_query()) info = asyncio.run(_run_query(test_call))
resp = info[test_call.module] resp = info[test_call.module]
except Exception as ex: except Exception as ex:
click.echo(click.style(f"FAIL {ex}", fg="red")) click.echo(click.style(f"FAIL {ex}", fg="red"))

View File

@ -15,7 +15,7 @@ from kasa.protocol import TPLinkSmartHomeProtocol
def read_payloads_from_file(file): def read_payloads_from_file(file):
"""Read the given pcap file and yield json payloads.""" """Read the given pcap file and yield json payloads."""
pcap = dpkt.pcap.Reader(file) pcap = dpkt.pcap.Reader(file)
for ts, pkt in pcap: for _ts, pkt in pcap:
eth = Ethernet(pkt) eth = Ethernet(pkt)
if eth.type != ETH_TYPE_IP: if eth.type != ETH_TYPE_IP:
continue continue
@ -44,9 +44,8 @@ def read_payloads_from_file(file):
try: try:
json_payload = json.loads(decrypted) json_payload = json.loads(decrypted)
except ( except Exception as ex:
Exception # this can happen when the response is split into multiple tcp segments
) as ex: # this can happen when the response is split into multiple tcp segments
echo(f"[red]Unable to parse payload '{decrypted}', ignoring: {ex}[/red]") echo(f"[red]Unable to parse payload '{decrypted}', ignoring: {ex}[/red]")
continue continue
@ -91,7 +90,8 @@ def parse_pcap(file):
context_str = f" [ctx: {context}]" if context else "" context_str = f" [ctx: {context}]" if context else ""
echo( echo(
f"[{is_success}] {direction}{context_str} {module}.{cmd}: {pf(response)}" f"[{is_success}] {direction}{context_str} {module}.{cmd}:"
f" {pf(response)}"
) )
echo(pf(seen_items)) echo(pf(seen_items))

View File

@ -59,13 +59,13 @@ async def main(addrs, rounds):
if test_gathered: if test_gathered:
print("=== Testing using gather on all devices ===") print("=== Testing using gather on all devices ===")
for i in range(rounds): for _i in range(rounds):
data.append(await _update_concurrently(devs)) data.append(await _update_concurrently(devs))
await asyncio.sleep(2) await asyncio.sleep(2)
await asyncio.sleep(5) await asyncio.sleep(5)
for i in range(rounds): for _i in range(rounds):
data.append(await _update_sequentially(devs)) data.append(await _update_sequentially(devs))
await asyncio.sleep(2) await asyncio.sleep(2)
@ -77,7 +77,7 @@ async def main(addrs, rounds):
futs = [] futs = []
data = [] data = []
locks = {dev: asyncio.Lock() for dev in devs} locks = {dev: asyncio.Lock() for dev in devs}
for i in range(rounds): for _i in range(rounds):
for dev in devs: for dev in devs:
futs.append(asyncio.ensure_future(_update(dev, locks[dev]))) futs.append(asyncio.ensure_future(_update(dev, locks[dev])))

View File

@ -203,7 +203,8 @@ async def cli(
except ImportError: except ImportError:
pass pass
# The configuration should be converted to use dictConfig, but this keeps mypy happy for now # The configuration should be converted to use dictConfig,
# but this keeps mypy happy for now
logging.basicConfig(**logging_config) # type: ignore logging.basicConfig(**logging_config) # type: ignore
if ctx.invoked_subcommand == "discover": if ctx.invoked_subcommand == "discover":
@ -278,7 +279,8 @@ async def join(dev: SmartDevice, ssid, password, keytype):
echo(f"Asking the device to connect to {ssid}..") echo(f"Asking the device to connect to {ssid}..")
res = await dev.wifi_join(ssid, password, keytype=keytype) res = await dev.wifi_join(ssid, password, keytype=keytype)
echo( echo(
f"Response: {res} - if the device is not able to join the network, it will revert back to its previous state." f"Response: {res} - if the device is not able to join the network, "
f"it will revert back to its previous state."
) )
return res return res
@ -347,9 +349,9 @@ async def discover(ctx, timeout, show_unsupported):
async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3):
"""Discover a device identified by its alias.""" """Discover a device identified by its alias."""
for attempt in range(1, attempts): for _attempt in range(1, attempts):
found_devs = await Discover.discover(target=target, timeout=timeout) found_devs = await Discover.discover(target=target, timeout=timeout)
for ip, dev in found_devs.items(): for _ip, dev in found_devs.items():
if dev.alias.lower() == alias.lower(): if dev.alias.lower() == alias.lower():
host = dev.host host = dev.host
return host return host

View File

@ -87,7 +87,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
req = json_dumps(Discover.DISCOVERY_QUERY) req = json_dumps(Discover.DISCOVERY_QUERY)
_LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY)
encrypted_req = TPLinkSmartHomeProtocol.encrypt(req) encrypted_req = TPLinkSmartHomeProtocol.encrypt(req)
for i in range(self.discovery_packets): for _i in range(self.discovery_packets):
self.transport.sendto(encrypted_req[4:], self.target) # type: ignore self.transport.sendto(encrypted_req[4:], self.target) # type: ignore
self.transport.sendto(Discover.DISCOVERY_QUERY_2, self.target_2) # type: ignore self.transport.sendto(Discover.DISCOVERY_QUERY_2, self.target_2) # type: ignore
@ -169,7 +169,8 @@ class Discover:
>>> [dev.alias for dev in found_devices] >>> [dev.alias for dev in found_devices]
['TP-LINK_Power Strip_CF69'] ['TP-LINK_Power Strip_CF69']
Discovery can also be targeted to a specific broadcast address instead of the 255.255.255.255: Discovery can also be targeted to a specific broadcast address instead of
the default 255.255.255.255:
>>> asyncio.run(Discover.discover(target="192.168.8.255")) >>> asyncio.run(Discover.discover(target="192.168.8.255"))
@ -207,14 +208,19 @@ class Discover:
Sends discovery message to 255.255.255.255:9999 in order Sends discovery message to 255.255.255.255:9999 in order
to detect available supported devices in the local network, to detect available supported devices in the local network,
and waits for given timeout for answers from devices. and waits for given timeout for answers from devices.
If you have multiple interfaces, you can use target parameter to specify the network for discovery. If you have multiple interfaces,
you can use *target* parameter to specify the network for discovery.
If given, `on_discovered` coroutine will get awaited with a :class:`SmartDevice`-derived object as parameter. If given, `on_discovered` coroutine will get awaited with
a :class:`SmartDevice`-derived object as parameter.
The results of the discovery are returned as a dict of :class:`SmartDevice`-derived objects keyed with IP addresses. The results of the discovery are returned as a dict of
The devices are already initialized and all but emeter-related properties can be accessed directly. :class:`SmartDevice`-derived objects keyed with IP addresses.
The devices are already initialized and all but emeter-related properties
can be accessed directly.
:param target: The target address where to send the broadcast discovery queries if multi-homing (e.g. 192.168.xxx.255). :param target: The target address where to send the broadcast discovery
queries if multi-homing (e.g. 192.168.xxx.255).
:param on_discovered: coroutine to execute on discovery :param on_discovered: coroutine to execute on discovery
:param timeout: How long to wait for responses, defaults to 5 :param timeout: How long to wait for responses, defaults to 5
:param discovery_packets: Number of discovery packets to broadcast :param discovery_packets: Number of discovery packets to broadcast
@ -232,7 +238,7 @@ class Discover:
credentials=credentials, credentials=credentials,
timeout=timeout, timeout=timeout,
), ),
local_addr=("0.0.0.0", 0), local_addr=("0.0.0.0", 0), # noqa: S104
) )
protocol = cast(_DiscoverProtocol, protocol) protocol = cast(_DiscoverProtocol, protocol)
@ -275,7 +281,7 @@ class Discover:
credentials=credentials, credentials=credentials,
timeout=timeout, timeout=timeout,
), ),
local_addr=("0.0.0.0", 0), local_addr=("0.0.0.0", 0), # noqa: S104
) )
protocol = cast(_DiscoverProtocol, protocol) protocol = cast(_DiscoverProtocol, protocol)
@ -284,10 +290,10 @@ class Discover:
async with asyncio_timeout(timeout): async with asyncio_timeout(timeout):
await event.wait() await event.wait()
except asyncio.TimeoutError: except asyncio.TimeoutError as ex:
raise SmartDeviceException( raise SmartDeviceException(
f"Timed out getting discovery response for {host}" f"Timed out getting discovery response for {host}"
) ) from ex
finally: finally:
transport.close() transport.close()

View File

@ -48,9 +48,13 @@ class EmeterStatus(dict):
return None return None
def __repr__(self): def __repr__(self):
return f"<EmeterStatus power={self.power} voltage={self.voltage} current={self.current} total={self.total}>" return (
f"<EmeterStatus power={self.power} voltage={self.voltage}"
f" current={self.current} total={self.total}>"
)
def __getitem__(self, item): def __getitem__(self, item):
"""Return value in wanted units."""
valid_keys = [ valid_keys = [
"voltage_mv", "voltage_mv",
"power_mw", "power_mw",
@ -65,7 +69,7 @@ class EmeterStatus(dict):
] ]
# 1. if requested data is available, return it # 1. if requested data is available, return it
if item in super().keys(): if item in super().keys(): # noqa: SIM118
return super().__getitem__(item) return super().__getitem__(item)
# otherwise decide how to convert it # otherwise decide how to convert it
else: else:
@ -74,7 +78,7 @@ class EmeterStatus(dict):
if "_" in item: # upscale if "_" in item: # upscale
return super().__getitem__(item[: item.find("_")]) * 1000 return super().__getitem__(item[: item.find("_")]) * 1000
else: # downscale else: # downscale
for i in super().keys(): for i in super().keys(): # noqa: SIM118
if i.startswith(item): if i.startswith(item):
return self.__getitem__(i) / 1000 return self.__getitem__(i) / 1000

View File

@ -44,13 +44,19 @@ class Emeter(Usage):
return await self.call("get_realtime") return await self.call("get_realtime")
async def get_daystat(self, *, year=None, month=None, kwh=True) -> Dict: async def get_daystat(self, *, year=None, month=None, kwh=True) -> Dict:
"""Return daily stats for the given year & month as a dictionary of {day: energy, ...}.""" """Return daily stats for the given year & month.
The return value is a dictionary of {day: energy, ...}.
"""
data = await self.get_raw_daystat(year=year, month=month) data = await self.get_raw_daystat(year=year, month=month)
data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh)
return data return data
async def get_monthstat(self, *, year=None, kwh=True) -> Dict: async def get_monthstat(self, *, year=None, kwh=True) -> Dict:
"""Return monthly stats for the given year as a dictionary of {month: energy, ...}.""" """Return monthly stats for the given year.
The return value is a dictionary of {month: energy, ...}.
"""
data = await self.get_raw_monthstat(year=year) data = await self.get_raw_monthstat(year=year)
data = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh) data = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh)
return data return data

View File

@ -57,7 +57,8 @@ class Module(ABC):
"""Return the module specific raw data from the last update.""" """Return the module specific raw data from the last update."""
if self._module not in self._device._last_update: if self._module not in self._device._last_update:
raise SmartDeviceException( raise SmartDeviceException(
f"You need to call update() prior accessing module data for '{self._module}'" f"You need to call update() prior accessing module data"
f" for '{self._module}'"
) )
return self._device._last_update[self._module] return self._device._last_update[self._module]
@ -80,4 +81,7 @@ class Module(ABC):
return self._device._create_request(self._module, query, params) return self._device._create_request(self._module, query, params)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Module {self.__class__.__name__} ({self._module}) for {self._device.host}>" return (
f"<Module {self.__class__.__name__} ({self._module})"
f" for {self._device.host}>"
)

View File

@ -73,13 +73,19 @@ class Usage(Module):
return await self.call("get_monthstat", {"year": year}) return await self.call("get_monthstat", {"year": year})
async def get_daystat(self, *, year=None, month=None) -> Dict: async def get_daystat(self, *, year=None, month=None) -> Dict:
"""Return daily stats for the given year & month as a dictionary of {day: time, ...}.""" """Return daily stats for the given year & month.
The return value is a dictionary of {day: time, ...}.
"""
data = await self.get_raw_daystat(year=year, month=month) data = await self.get_raw_daystat(year=year, month=month)
data = self._convert_stat_data(data["day_list"], entry_key="day") data = self._convert_stat_data(data["day_list"], entry_key="day")
return data return data
async def get_monthstat(self, *, year=None) -> Dict: async def get_monthstat(self, *, year=None) -> Dict:
"""Return monthly stats for the given year as a dictionary of {month: time, ...}.""" """Return monthly stats for the given year.
The return value is a dictionary of {month: time, ...}.
"""
data = await self.get_raw_monthstat(year=year) data = await self.get_raw_monthstat(year=year)
data = self._convert_stat_data(data["month_list"], entry_key="month") data = self._convert_stat_data(data["month_list"], entry_key="month")
return data return data

View File

@ -60,7 +60,7 @@ class TPLinkSmartHomeProtocol:
""" """
if isinstance(request, dict): if isinstance(request, dict):
request = json_dumps(request) request = json_dumps(request)
assert isinstance(request, str) assert isinstance(request, str) # noqa: S101
async with self.query_lock: async with self.query_lock:
return await self._query(request, retry_count, self.timeout) return await self._query(request, retry_count, self.timeout)
@ -77,8 +77,8 @@ class TPLinkSmartHomeProtocol:
async def _execute_query(self, request: str) -> Dict: async def _execute_query(self, request: str) -> Dict:
"""Execute a query on the device and wait for the response.""" """Execute a query on the device and wait for the response."""
assert self.writer is not None assert self.writer is not None # noqa: S101
assert self.reader is not None assert self.reader is not None # noqa: S101
debug_log = _LOGGER.isEnabledFor(logging.DEBUG) debug_log = _LOGGER.isEnabledFor(logging.DEBUG)
if debug_log: if debug_log:
@ -116,11 +116,11 @@ class TPLinkSmartHomeProtocol:
# Most of the time we will already be connected if the device is online # Most of the time we will already be connected if the device is online
# and the connect call will do nothing and return right away # and the connect call will do nothing and return right away
# #
# However, if we get an unrecoverable error (_NO_RETRY_ERRORS and ConnectionRefusedError) # However, if we get an unrecoverable error (_NO_RETRY_ERRORS and
# we do not want to keep trying since many connection open/close operations # ConnectionRefusedError) we do not want to keep trying since many
# in the same time frame can block the event loop. This is especially # connection open/close operations in the same time frame can block
# import when there are multiple tplink devices being polled. # the event loop.
# # This is especially import when there are multiple tplink devices being polled.
for retry in range(retry_count + 1): for retry in range(retry_count + 1):
try: try:
await self._connect(timeout) await self._connect(timeout)
@ -128,26 +128,28 @@ class TPLinkSmartHomeProtocol:
await self.close() await self.close()
raise SmartDeviceException( raise SmartDeviceException(
f"Unable to connect to the device: {self.host}:{self.port}: {ex}" f"Unable to connect to the device: {self.host}:{self.port}: {ex}"
) ) from ex
except OSError as ex: except OSError as ex:
await self.close() await self.close()
if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count: if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count:
raise SmartDeviceException( raise SmartDeviceException(
f"Unable to connect to the device: {self.host}:{self.port}: {ex}" f"Unable to connect to the device:"
) f" {self.host}:{self.port}: {ex}"
) from ex
continue continue
except Exception as ex: except Exception as ex:
await self.close() await self.close()
if retry >= retry_count: if retry >= retry_count:
_LOGGER.debug("Giving up on %s after %s retries", self.host, retry) _LOGGER.debug("Giving up on %s after %s retries", self.host, retry)
raise SmartDeviceException( raise SmartDeviceException(
f"Unable to connect to the device: {self.host}:{self.port}: {ex}" f"Unable to connect to the device:"
) f" {self.host}:{self.port}: {ex}"
) from ex
continue continue
try: try:
assert self.reader is not None assert self.reader is not None # noqa: S101
assert self.writer is not None assert self.writer is not None # noqa: S101
async with asyncio_timeout(timeout): async with asyncio_timeout(timeout):
return await self._execute_query(request) return await self._execute_query(request)
except Exception as ex: except Exception as ex:

View File

@ -59,7 +59,8 @@ class TurnOnBehavior(BaseModel):
"""Model to present a single turn on behavior. """Model to present a single turn on behavior.
:param int preset: the index number of wanted preset. :param int preset: the index number of wanted preset.
:param BehaviorMode mode: last status or preset mode. If you are changing existing settings, you should not set this manually. :param BehaviorMode mode: last status or preset mode.
If you are changing existing settings, you should not set this manually.
To change the behavior, it is only necessary to change the :attr:`preset` field To change the behavior, it is only necessary to change the :attr:`preset` field
to contain either the preset index, or ``None`` for the last known state. to contain either the preset index, or ``None`` for the last known state.
@ -121,9 +122,11 @@ class SmartBulb(SmartDevice):
This will allow accessing the properties using the exposed properties. This will allow accessing the properties using the exposed properties.
All changes to the device are done using awaitable methods, All changes to the device are done using awaitable methods,
which will not change the cached values, but you must await :func:`update()` separately. which will not change the cached values,
so you must await :func:`update()` to fetch updates values from the device.
Errors reported by the device are raised as :class:`SmartDeviceExceptions <kasa.exceptions.SmartDeviceException>`, Errors reported by the device are raised as
:class:`SmartDeviceExceptions <kasa.exceptions.SmartDeviceException>`,
and should be handled by the user of the library. and should be handled by the user of the library.
Examples: Examples:
@ -159,7 +162,7 @@ class SmartBulb(SmartDevice):
>>> bulb.brightness >>> bulb.brightness
50 50
Bulbs supporting color temperature can be queried to know which range is accepted: Bulbs supporting color temperature can be queried for the supported range:
>>> bulb.valid_temperature_range >>> bulb.valid_temperature_range
ColorTempRange(min=2500, max=9000) ColorTempRange(min=2500, max=9000)
@ -175,9 +178,18 @@ class SmartBulb(SmartDevice):
>>> bulb.hsv >>> bulb.hsv
HSV(hue=180, saturation=100, value=80) HSV(hue=180, saturation=100, value=80)
If you don't want to use the default transitions, you can pass `transition` in milliseconds. If you don't want to use the default transitions,
This applies to all transitions (:func:`turn_on`, :func:`turn_off`, :func:`set_hsv`, :func:`set_color_temp`, :func:`set_brightness`) if supported by the device. you can pass `transition` in milliseconds.
Light strips (e.g., KL420L5) do not support this feature, but silently ignore the parameter. All methods changing the state of the device support this parameter:
* :func:`turn_on`
* :func:`turn_off`
* :func:`set_hsv`
* :func:`set_color_temp`
* :func:`set_brightness`
Light strips (e.g., KL420L5) do not support this feature,
but silently ignore the parameter.
The following changes the brightness over a period of 10 seconds: The following changes the brightness over a period of 10 seconds:
>>> asyncio.run(bulb.set_brightness(100, transition=10_000)) >>> asyncio.run(bulb.set_brightness(100, transition=10_000))
@ -187,7 +199,8 @@ class SmartBulb(SmartDevice):
>>> bulb.presets >>> bulb.presets
[SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] [SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)]
To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` instance to :func:`save_preset` method: To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset`
instance to :func:`save_preset` method:
>>> preset = bulb.presets[0] >>> preset = bulb.presets[0]
>>> preset.brightness >>> preset.brightness
@ -197,7 +210,7 @@ class SmartBulb(SmartDevice):
>>> bulb.presets[0].brightness >>> bulb.presets[0].brightness
100 100
""" """ # noqa: E501
LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice"
SET_LIGHT_METHOD = "transition_light_state" SET_LIGHT_METHOD = "transition_light_state"
@ -362,9 +375,7 @@ class SmartBulb(SmartDevice):
def _raise_for_invalid_brightness(self, value): def _raise_for_invalid_brightness(self, value):
if not isinstance(value, int) or not (0 <= value <= 100): if not isinstance(value, int) or not (0 <= value <= 100):
raise ValueError( raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)")
"Invalid brightness value: {} " "(valid range: 0-100%)".format(value)
)
@requires_update @requires_update
async def set_hsv( async def set_hsv(
@ -386,14 +397,11 @@ class SmartBulb(SmartDevice):
raise SmartDeviceException("Bulb does not support color.") raise SmartDeviceException("Bulb does not support color.")
if not isinstance(hue, int) or not (0 <= hue <= 360): if not isinstance(hue, int) or not (0 <= hue <= 360):
raise ValueError( raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)")
"Invalid hue value: {} " "(valid range: 0-360)".format(hue)
)
if not isinstance(saturation, int) or not (0 <= saturation <= 100): if not isinstance(saturation, int) or not (0 <= saturation <= 100):
raise ValueError( raise ValueError(
"Invalid saturation value: {} " f"Invalid saturation value: {saturation} (valid range: 0-100%)"
"(valid range: 0-100%)".format(saturation)
) )
light_state = { light_state = {
@ -433,8 +441,9 @@ class SmartBulb(SmartDevice):
valid_temperature_range = self.valid_temperature_range valid_temperature_range = self.valid_temperature_range
if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]:
raise ValueError( raise ValueError(
"Temperature should be between {} " "Temperature should be between {} and {}, was {}".format(
"and {}, was {}".format(*valid_temperature_range, temp) *valid_temperature_range, temp
)
) )
light_state = {"color_temp": temp} light_state = {"color_temp": temp}

View File

@ -101,8 +101,8 @@ def _parse_features(features: str) -> Set[str]:
class SmartDevice: class SmartDevice:
"""Base class for all supported device types. """Base class for all supported device types.
You don't usually want to construct this class which implements the shared common interfaces. You don't usually want to initialize this class manually,
The recommended way is to either use the discovery functionality, or construct one of the subclasses: but either use :class:`Discover` class, or use one of the subclasses:
* :class:`SmartPlug` * :class:`SmartPlug`
* :class:`SmartBulb` * :class:`SmartBulb`
@ -145,7 +145,8 @@ class SmartDevice:
>>> dev.mac >>> dev.mac
01:23:45:67:89:ab 01:23:45:67:89:ab
When initialized using discovery or using a subclass, you can check the type of the device: When initialized using discovery or using a subclass,
you can check the type of the device:
>>> dev.is_bulb >>> dev.is_bulb
False False
@ -154,7 +155,8 @@ class SmartDevice:
>>> dev.is_plug >>> dev.is_plug
True True
You can also get the hardware and software as a dict, or access the full device response: You can also get the hardware and software as a dict,
or access the full device response:
>>> dev.hw_info >>> dev.hw_info
{'sw_ver': '1.2.5 Build 171213 Rel.101523', {'sw_ver': '1.2.5 Build 171213 Rel.101523',
@ -175,7 +177,8 @@ class SmartDevice:
>>> dev.is_on >>> dev.is_on
True True
Some devices provide energy consumption meter, and regular update will already fetch some information: Some devices provide energy consumption meter,
and regular update will already fetch some information:
>>> dev.has_emeter >>> dev.has_emeter
True True
@ -184,7 +187,8 @@ class SmartDevice:
>>> dev.emeter_today >>> dev.emeter_today
>>> dev.emeter_this_month >>> dev.emeter_this_month
You can also query the historical data (note that these needs to be awaited), keyed with month/day: You can also query the historical data (note that these needs to be awaited),
keyed with month/day:
>>> asyncio.run(dev.get_emeter_monthly(year=2016)) >>> asyncio.run(dev.get_emeter_monthly(year=2016))
{11: 1.089, 12: 1.582} {11: 1.089, 12: 1.582}
@ -214,9 +218,9 @@ class SmartDevice:
self.credentials = credentials self.credentials = credentials
_LOGGER.debug("Initializing %s of type %s", self.host, type(self)) _LOGGER.debug("Initializing %s of type %s", self.host, type(self))
self._device_type = DeviceType.Unknown self._device_type = DeviceType.Unknown
# TODO: typing Any is just as using Optional[Dict] would require separate checks in # TODO: typing Any is just as using Optional[Dict] would require separate
# accessors. the @updated_required decorator does not ensure mypy that these # checks in accessors. the @updated_required decorator does not ensure
# are not accessed incorrectly. # mypy that these are not accessed incorrectly.
self._last_update: Any = None self._last_update: Any = None
self._sys_info: Any = None # TODO: this is here to avoid changing tests self._sys_info: Any = None # TODO: this is here to avoid changing tests
self._features: Set[str] = set() self._features: Set[str] = set()
@ -230,8 +234,6 @@ class SmartDevice:
_LOGGER.debug("Module %s already registered, ignoring..." % name) _LOGGER.debug("Module %s already registered, ignoring..." % name)
return return
assert name not in self.modules
_LOGGER.debug("Adding module %s", module) _LOGGER.debug("Adding module %s", module)
self.modules[name] = module self.modules[name] = module
@ -751,4 +753,8 @@ class SmartDevice:
def __repr__(self): def __repr__(self):
if self._last_update is None: if self._last_update is None:
return f"<{self._device_type} at {self.host} - update() needed>" return f"<{self._device_type} at {self.host} - update() needed>"
return f"<{self._device_type} model {self.model} at {self.host} ({self.alias}), is_on: {self.is_on} - dev specific: {self.state_information}>" return (
f"<{self._device_type} model {self.model} at {self.host}"
f" ({self.alias}), is_on: {self.is_on}"
f" - dev specific: {self.state_information}>"
)

View File

@ -41,7 +41,8 @@ class SmartDimmer(SmartPlug):
This will allow accessing the properties using the exposed properties. This will allow accessing the properties using the exposed properties.
All changes to the device are done using awaitable methods, All changes to the device are done using awaitable methods,
which will not change the cached values, but you must await :func:`update()` separately. which will not change the cached values,
but you must await :func:`update()` separately.
Errors reported by the device are raised as :class:`SmartDeviceException`\s, Errors reported by the device are raised as :class:`SmartDeviceException`\s,
and should be handled by the user of the library. and should be handled by the user of the library.
@ -74,7 +75,7 @@ class SmartDimmer(SmartPlug):
super().__init__(host, port=port, credentials=credentials, timeout=timeout) super().__init__(host, port=port, credentials=credentials, timeout=timeout)
self._device_type = DeviceType.Dimmer self._device_type = DeviceType.Dimmer
# TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be verified if it's okay to call these on HS220 w/o these
# TODO: need to be figured out what's the best approach to detect support for these # TODO: need to be figured out what's the best approach to detect support
self.add_module("motion", Motion(self, "smartlife.iot.PIR")) self.add_module("motion", Motion(self, "smartlife.iot.PIR"))
self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS")) self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS"))
@ -187,7 +188,8 @@ class SmartDimmer(SmartPlug):
"""Set action to perform on button click/hold. """Set action to perform on button click/hold.
:param action_type ActionType: whether to control double click or hold action. :param action_type ActionType: whether to control double click or hold action.
:param action ButtonAction: what should the button do (nothing, instant, gentle, change preset) :param action ButtonAction: what should the button do
(nothing, instant, gentle, change preset)
:param index int: in case of preset change, the preset to select :param index int: in case of preset change, the preset to select
""" """
action_type_setter = f"set_{action_type}" action_type_setter = f"set_{action_type}"

View File

@ -11,10 +11,10 @@ class SmartLightStrip(SmartBulb):
"""Representation of a TP-Link Smart light strip. """Representation of a TP-Link Smart light strip.
Light strips work similarly to bulbs, but use a different service for controlling, Light strips work similarly to bulbs, but use a different service for controlling,
and expose some extra information (such as length and active effect). and expose some extra information (such as length and active effect).
This class extends :class:`SmartBulb` interface. This class extends :class:`SmartBulb` interface.
Examples: Examples:
>>> import asyncio >>> import asyncio
>>> strip = SmartLightStrip("127.0.0.1") >>> strip = SmartLightStrip("127.0.0.1")
>>> asyncio.run(strip.update()) >>> asyncio.run(strip.update())
@ -105,9 +105,11 @@ class SmartLightStrip(SmartBulb):
) -> None: ) -> None:
"""Set an effect on the device. """Set an effect on the device.
If brightness or transition is defined, its value will be used instead of the effect-specific default. If brightness or transition is defined,
its value will be used instead of the effect-specific default.
See :meth:`effect_list` for available effects, or use :meth:`set_custom_effect` for custom effects. See :meth:`effect_list` for available effects,
or use :meth:`set_custom_effect` for custom effects.
:param str effect: The effect to set :param str effect: The effect to set
:param int brightness: The wanted brightness :param int brightness: The wanted brightness

View File

@ -16,7 +16,8 @@ class SmartPlug(SmartDevice):
This will allow accessing the properties using the exposed properties. This will allow accessing the properties using the exposed properties.
All changes to the device are done using awaitable methods, All changes to the device are done using awaitable methods,
which will not change the cached values, but you must await :func:`update()` separately. which will not change the cached values,
but you must await :func:`update()` separately.
Errors reported by the device are raised as :class:`SmartDeviceException`\s, Errors reported by the device are raised as :class:`SmartDeviceException`\s,
and should be handled by the user of the library. and should be handled by the user of the library.

View File

@ -40,7 +40,8 @@ class SmartStrip(SmartDevice):
This will allow accessing the properties using the exposed properties. This will allow accessing the properties using the exposed properties.
All changes to the device are done using awaitable methods, All changes to the device are done using awaitable methods,
which will not change the cached values, but you must await :func:`update()` separately. which will not change the cached values,
but you must await :func:`update()` separately.
Errors reported by the device are raised as :class:`SmartDeviceException`\s, Errors reported by the device are raised as :class:`SmartDeviceException`\s,
and should be handled by the user of the library. and should be handled by the user of the library.

View File

@ -1,8 +1,16 @@
import logging import logging
import re import re
from voluptuous import Coerce # type: ignore from voluptuous import (
from voluptuous import REMOVE_EXTRA, All, Any, Invalid, Optional, Range, Schema REMOVE_EXTRA,
All,
Any,
Coerce, # type: ignore
Invalid,
Optional,
Range,
Schema,
)
from ..protocol import TPLinkSmartHomeProtocol from ..protocol import TPLinkSmartHomeProtocol
@ -305,7 +313,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
self.proto = proto self.proto = proto
def set_alias(self, x, child_ids=[]): def set_alias(self, x, child_ids=None):
if child_ids is None:
child_ids = []
_LOGGER.debug("Setting alias to %s, child_ids: %s", x["alias"], child_ids) _LOGGER.debug("Setting alias to %s, child_ids: %s", x["alias"], child_ids)
if child_ids: if child_ids:
for child in self.proto["system"]["get_sysinfo"]["children"]: for child in self.proto["system"]["get_sysinfo"]["children"]:
@ -314,7 +324,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
else: else:
self.proto["system"]["get_sysinfo"]["alias"] = x["alias"] self.proto["system"]["get_sysinfo"]["alias"] = x["alias"]
def set_relay_state(self, x, child_ids=[]): def set_relay_state(self, x, child_ids=None):
if child_ids is None:
child_ids = []
_LOGGER.debug("Setting relay state to %s", x["state"]) _LOGGER.debug("Setting relay state to %s", x["state"])
if not child_ids and "children" in self.proto["system"]["get_sysinfo"]: if not child_ids and "children" in self.proto["system"]["get_sysinfo"]:
@ -362,12 +374,10 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
_LOGGER.debug("Current light state: %s", light_state) _LOGGER.debug("Current light state: %s", light_state)
new_state = light_state new_state = light_state
if state_changes["on_off"] == 1: # turn on requested # turn on requested, if we were off, use the dft_on_state as a base
if not light_state[ if state_changes["on_off"] == 1 and not light_state["on_off"]:
"on_off" _LOGGER.debug("Bulb was off, using dft_on_state")
]: # if we were off, use the dft_on_state as a base new_state = light_state["dft_on_state"]
_LOGGER.debug("Bulb was off, using dft_on_state")
new_state = light_state["dft_on_state"]
# override the existing settings # override the existing settings
new_state.update(state_changes) new_state.update(state_changes)
@ -384,7 +394,7 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
self.proto["system"]["get_sysinfo"]["light_state"] = new_state self.proto["system"]["get_sysinfo"]["light_state"] = new_state
def set_preferred_state(self, new_state, *args): def set_preferred_state(self, new_state, *args):
"""Implementation of set_preferred_state.""" """Implement set_preferred_state."""
self.proto["system"]["get_sysinfo"]["preferred_state"][ self.proto["system"]["get_sysinfo"]["preferred_state"][
new_state["index"] new_state["index"]
] = new_state ] = new_state
@ -459,11 +469,11 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
child_ids = [] child_ids = []
def get_response_for_module(target): def get_response_for_module(target):
if target not in proto.keys(): if target not in proto:
return error(msg="target not found") return error(msg="target not found")
def get_response_for_command(cmd): def get_response_for_command(cmd):
if cmd not in proto[target].keys(): if cmd not in proto[target]:
return error(msg=f"command {cmd} not found") return error(msg=f"command {cmd} not found")
params = request[target][cmd] params = request[target][cmd]

View File

@ -156,7 +156,7 @@ async def test_credentials(discovery_data: dict, mocker):
mocker.patch("kasa.cli.state", new=_state) mocker.patch("kasa.cli.state", new=_state)
# Get the type string parameter from the discovery_info # Get the type string parameter from the discovery_info
for cli_device_type in { for cli_device_type in { # noqa: B007
i i
for i in TYPE_TO_CLASS for i in TYPE_TO_CLASS
if TYPE_TO_CLASS[i] == Discover._get_device_class(discovery_data) if TYPE_TO_CLASS[i] == Discover._get_device_class(discovery_data)

View File

@ -71,7 +71,7 @@ async def test_discover_single(discovery_data: dict, mocker, custom_port):
x = await Discover.discover_single(host, port=custom_port) x = await Discover.discover_single(host, port=custom_port)
assert issubclass(x.__class__, SmartDevice) assert issubclass(x.__class__, SmartDevice)
assert x._sys_info is not None assert x._sys_info is not None
assert x.port == custom_port or 9999 assert x.port == custom_port or x.port == 9999
@pytest.mark.parametrize("custom_port", [123, None]) @pytest.mark.parametrize("custom_port", [123, None])
@ -82,7 +82,7 @@ async def test_connect_single(discovery_data: dict, mocker, custom_port):
dev = await Discover.connect_single(host, port=custom_port) dev = await Discover.connect_single(host, port=custom_port)
assert issubclass(dev.__class__, SmartDevice) assert issubclass(dev.__class__, SmartDevice)
assert dev.port == custom_port or 9999 assert dev.port == custom_port or dev.port == 9999
async def test_connect_single_query_fails(discovery_data: dict, mocker): async def test_connect_single_query_fails(discovery_data: dict, mocker):

View File

@ -28,9 +28,10 @@ async def test_state_info(dev):
@pytest.mark.requires_dummy @pytest.mark.requires_dummy
async def test_invalid_connection(dev): async def test_invalid_connection(dev):
with patch.object(FakeTransportProtocol, "query", side_effect=SmartDeviceException): with patch.object(
with pytest.raises(SmartDeviceException): FakeTransportProtocol, "query", side_effect=SmartDeviceException
await dev.update() ), pytest.raises(SmartDeviceException):
await dev.update()
@has_emeter @has_emeter

View File

@ -54,12 +54,6 @@ coverage = {version = "*", extras = ["toml"]}
docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"]
speedups = ["orjson", "kasa-crypt"] speedups = ["orjson", "kasa-crypt"]
[tool.isort]
profile = "black"
known_first_party = "kasa"
known_third_party = ["asyncclick", "pytest", "setuptools", "voluptuous"]
[tool.coverage.run] [tool.coverage.run]
source = ["kasa"] source = ["kasa"]
branch = true branch = true
@ -72,15 +66,6 @@ exclude_lines = [
"def __repr__" "def __repr__"
] ]
[tool.interrogate]
ignore-init-method = true
ignore-magic = true
ignore-private = true
ignore-semiprivate = true
fail-under = 100
exclude = ['kasa/tests/*']
verbose = 2
[tool.pytest.ini_options] [tool.pytest.ini_options]
markers = [ markers = [
"requires_dummy: test requires dummy data to pass, skipped on real devices", "requires_dummy: test requires dummy data to pass, skipped on real devices",
@ -95,3 +80,39 @@ ignore-path-errors = ["docs/source/index.rst;D000"]
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.ruff]
target-version = "py38"
select = [
"E", # pycodestyle
"D", # pydocstyle
"F", # pyflakes
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"I", # isort
"S", # bandit
]
ignore = [
"D105", # Missing docstring in magic method
"D107", # Missing docstring in `__init__`
]
[tool.ruff.pydocstyle]
convention = "pep257"
[tool.ruff.per-file-ignores]
"kasa/tests/*.py" = [
"D100",
"D101",
"D102",
"D103",
"D104",
"F401",
"S101", # allow asserts
"E501", # ignore line-too-longs
]
"docs/source/conf.py" = [
"D100",
"D103",
]

View File

@ -1,5 +1,5 @@
[tox] [tox]
envlist=py37,py38,flake8,lint,coverage envlist=py37,py38,lint,coverage
skip_missing_interpreters = True skip_missing_interpreters = True
isolated_build = True isolated_build = True
@ -31,12 +31,6 @@ commands =
coverage report coverage report
coverage html coverage html
[testenv:flake8]
deps=
flake8
flake8-docstrings
commands=flake8 kasa
[testenv:lint] [testenv:lint]
deps = pre-commit deps = pre-commit
skip_install = true skip_install = true