diff --git a/.flake8 b/.flake8 deleted file mode 100644 index d4e8f681..00000000 --- a/.flake8 +++ /dev/null @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0dce418..9f855248 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,18 +26,9 @@ jobs: run: | python -m pip install --upgrade pip poetry poetry install - - name: "Run pyupgrade" + - name: "Linting and code formating (ruff)" run: | - poetry run pre-commit run pyupgrade --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 + poetry run pre-commit run ruff --all-files - name: "Typing checks (mypy)" run: | poetry run pre-commit run mypy --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b429e4d..4bbfd8c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,28 +9,12 @@ repos: - id: debug-statements - id: check-ast -- repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.3 hooks: - - id: pyupgrade - args: ['--py38-plus'] - -- 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] + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.3.0 diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py index b91b9fa9..2c1e7d95 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -1,7 +1,8 @@ """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 -readme = open("README.md").read() +with open("README.md") as f: + readme = f.read() typemap = { "light strips": LIGHT_STRIPS, diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 2f3c20e0..eff3a7b6 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -1,7 +1,8 @@ """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, -feel free to run this script and create a PR to add the file to the repository. +If you have new, yet unsupported device or a device with no devinfo file under + 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, 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: - async def _run_query(): + async def _run_query(test_call): protocol = TPLinkSmartHomeProtocol(host) return await protocol.query({test_call.module: {test_call.method: None}}) try: 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] except Exception as ex: click.echo(click.style(f"FAIL {ex}", fg="red")) diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py index b3ba2fae..5e741623 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -15,7 +15,7 @@ from kasa.protocol import TPLinkSmartHomeProtocol def read_payloads_from_file(file): """Read the given pcap file and yield json payloads.""" pcap = dpkt.pcap.Reader(file) - for ts, pkt in pcap: + for _ts, pkt in pcap: eth = Ethernet(pkt) if eth.type != ETH_TYPE_IP: continue @@ -44,9 +44,8 @@ def read_payloads_from_file(file): try: json_payload = json.loads(decrypted) - except ( - Exception - ) as ex: # this can happen when the response is split into multiple tcp segments + except Exception 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]") continue @@ -91,7 +90,8 @@ def parse_pcap(file): context_str = f" [ctx: {context}]" if context else "" 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)) diff --git a/devtools/perftest.py b/devtools/perftest.py index 5babc75f..55c57f14 100644 --- a/devtools/perftest.py +++ b/devtools/perftest.py @@ -59,13 +59,13 @@ async def main(addrs, rounds): if test_gathered: print("=== Testing using gather on all devices ===") - for i in range(rounds): + for _i in range(rounds): data.append(await _update_concurrently(devs)) await asyncio.sleep(2) await asyncio.sleep(5) - for i in range(rounds): + for _i in range(rounds): data.append(await _update_sequentially(devs)) await asyncio.sleep(2) @@ -77,7 +77,7 @@ async def main(addrs, rounds): futs = [] data = [] locks = {dev: asyncio.Lock() for dev in devs} - for i in range(rounds): + for _i in range(rounds): for dev in devs: futs.append(asyncio.ensure_future(_update(dev, locks[dev]))) diff --git a/kasa/cli.py b/kasa/cli.py index 17b8b366..3bc77934 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -203,7 +203,8 @@ async def cli( except ImportError: 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 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}..") res = await dev.wifi_join(ssid, password, keytype=keytype) 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 @@ -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): """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) - for ip, dev in found_devs.items(): + for _ip, dev in found_devs.items(): if dev.alias.lower() == alias.lower(): host = dev.host return host diff --git a/kasa/discover.py b/kasa/discover.py index 4cb7a532..b43df57b 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -87,7 +87,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): req = json_dumps(Discover.DISCOVERY_QUERY) _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) 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(Discover.DISCOVERY_QUERY_2, self.target_2) # type: ignore @@ -169,7 +169,8 @@ class Discover: >>> [dev.alias for dev in found_devices] ['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")) @@ -207,14 +208,19 @@ class Discover: Sends discovery message to 255.255.255.255:9999 in order to detect available supported devices in the local network, 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 devices are already initialized and all but emeter-related properties can be accessed directly. + The results of the discovery are returned as a dict of + :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 timeout: How long to wait for responses, defaults to 5 :param discovery_packets: Number of discovery packets to broadcast @@ -232,7 +238,7 @@ class Discover: credentials=credentials, timeout=timeout, ), - local_addr=("0.0.0.0", 0), + local_addr=("0.0.0.0", 0), # noqa: S104 ) protocol = cast(_DiscoverProtocol, protocol) @@ -275,7 +281,7 @@ class Discover: credentials=credentials, timeout=timeout, ), - local_addr=("0.0.0.0", 0), + local_addr=("0.0.0.0", 0), # noqa: S104 ) protocol = cast(_DiscoverProtocol, protocol) @@ -284,10 +290,10 @@ class Discover: async with asyncio_timeout(timeout): await event.wait() - except asyncio.TimeoutError: + except asyncio.TimeoutError as ex: raise SmartDeviceException( f"Timed out getting discovery response for {host}" - ) + ) from ex finally: transport.close() diff --git a/kasa/emeterstatus.py b/kasa/emeterstatus.py index da636551..48d6e241 100644 --- a/kasa/emeterstatus.py +++ b/kasa/emeterstatus.py @@ -48,9 +48,13 @@ class EmeterStatus(dict): return None def __repr__(self): - return f"" + return ( + f"" + ) def __getitem__(self, item): + """Return value in wanted units.""" valid_keys = [ "voltage_mv", "power_mw", @@ -65,7 +69,7 @@ class EmeterStatus(dict): ] # 1. if requested data is available, return it - if item in super().keys(): + if item in super().keys(): # noqa: SIM118 return super().__getitem__(item) # otherwise decide how to convert it else: @@ -74,7 +78,7 @@ class EmeterStatus(dict): if "_" in item: # upscale return super().__getitem__(item[: item.find("_")]) * 1000 else: # downscale - for i in super().keys(): + for i in super().keys(): # noqa: SIM118 if i.startswith(item): return self.__getitem__(i) / 1000 diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py index 831210c3..1fe6e679 100644 --- a/kasa/modules/emeter.py +++ b/kasa/modules/emeter.py @@ -44,13 +44,19 @@ class Emeter(Usage): return await self.call("get_realtime") 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 = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) return data 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 = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh) return data diff --git a/kasa/modules/module.py b/kasa/modules/module.py index ff806a99..40890f29 100644 --- a/kasa/modules/module.py +++ b/kasa/modules/module.py @@ -57,7 +57,8 @@ class Module(ABC): """Return the module specific raw data from the last update.""" if self._module not in self._device._last_update: 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] @@ -80,4 +81,7 @@ class Module(ABC): return self._device._create_request(self._module, query, params) def __repr__(self) -> str: - return f"" + return ( + f"" + ) diff --git a/kasa/modules/usage.py b/kasa/modules/usage.py index 9877795d..f33c71f1 100644 --- a/kasa/modules/usage.py +++ b/kasa/modules/usage.py @@ -73,13 +73,19 @@ class Usage(Module): return await self.call("get_monthstat", {"year": year}) 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 = self._convert_stat_data(data["day_list"], entry_key="day") return data 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 = self._convert_stat_data(data["month_list"], entry_key="month") return data diff --git a/kasa/protocol.py b/kasa/protocol.py index e695510a..7ab2c47f 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -60,7 +60,7 @@ class TPLinkSmartHomeProtocol: """ if isinstance(request, dict): request = json_dumps(request) - assert isinstance(request, str) + assert isinstance(request, str) # noqa: S101 async with self.query_lock: return await self._query(request, retry_count, self.timeout) @@ -77,8 +77,8 @@ class TPLinkSmartHomeProtocol: async def _execute_query(self, request: str) -> Dict: """Execute a query on the device and wait for the response.""" - assert self.writer is not None - assert self.reader is not None + assert self.writer is not None # noqa: S101 + assert self.reader is not None # noqa: S101 debug_log = _LOGGER.isEnabledFor(logging.DEBUG) if debug_log: @@ -116,11 +116,11 @@ class TPLinkSmartHomeProtocol: # 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 # - # However, if we get an unrecoverable error (_NO_RETRY_ERRORS and ConnectionRefusedError) - # we do not want to keep trying since many connection open/close operations - # in the same time frame can block the event loop. This is especially - # import when there are multiple tplink devices being polled. - # + # However, if we get an unrecoverable error (_NO_RETRY_ERRORS and + # ConnectionRefusedError) we do not want to keep trying since many + # connection open/close operations in the same time frame can block + # the event loop. + # This is especially import when there are multiple tplink devices being polled. for retry in range(retry_count + 1): try: await self._connect(timeout) @@ -128,26 +128,28 @@ class TPLinkSmartHomeProtocol: await self.close() raise SmartDeviceException( f"Unable to connect to the device: {self.host}:{self.port}: {ex}" - ) + ) from ex except OSError as ex: await self.close() if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count: 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 except Exception as ex: await self.close() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) 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 try: - assert self.reader is not None - assert self.writer is not None + assert self.reader is not None # noqa: S101 + assert self.writer is not None # noqa: S101 async with asyncio_timeout(timeout): return await self._execute_query(request) except Exception as ex: diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 09d42053..6dd4513c 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -59,7 +59,8 @@ class TurnOnBehavior(BaseModel): """Model to present a single turn on behavior. :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 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. 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 `, + Errors reported by the device are raised as + :class:`SmartDeviceExceptions `, and should be handled by the user of the library. Examples: @@ -159,7 +162,7 @@ class SmartBulb(SmartDevice): >>> bulb.brightness 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 ColorTempRange(min=2500, max=9000) @@ -175,9 +178,18 @@ class SmartBulb(SmartDevice): >>> bulb.hsv HSV(hue=180, saturation=100, value=80) - If you don't want to use the default transitions, you can pass `transition` in milliseconds. - 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. - Light strips (e.g., KL420L5) do not support this feature, but silently ignore the parameter. + If you don't want to use the default transitions, + you can pass `transition` in milliseconds. + 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: >>> asyncio.run(bulb.set_brightness(100, transition=10_000)) @@ -187,7 +199,8 @@ class SmartBulb(SmartDevice): >>> 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)] - 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.brightness @@ -197,7 +210,7 @@ class SmartBulb(SmartDevice): >>> bulb.presets[0].brightness 100 - """ + """ # noqa: E501 LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" SET_LIGHT_METHOD = "transition_light_state" @@ -362,9 +375,7 @@ class SmartBulb(SmartDevice): def _raise_for_invalid_brightness(self, value): if not isinstance(value, int) or not (0 <= value <= 100): - raise ValueError( - "Invalid brightness value: {} " "(valid range: 0-100%)".format(value) - ) + raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") @requires_update async def set_hsv( @@ -386,14 +397,11 @@ class SmartBulb(SmartDevice): raise SmartDeviceException("Bulb does not support color.") if not isinstance(hue, int) or not (0 <= hue <= 360): - raise ValueError( - "Invalid hue value: {} " "(valid range: 0-360)".format(hue) - ) + raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") if not isinstance(saturation, int) or not (0 <= saturation <= 100): raise ValueError( - "Invalid saturation value: {} " - "(valid range: 0-100%)".format(saturation) + f"Invalid saturation value: {saturation} (valid range: 0-100%)" ) light_state = { @@ -433,8 +441,9 @@ class SmartBulb(SmartDevice): valid_temperature_range = self.valid_temperature_range if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: raise ValueError( - "Temperature should be between {} " - "and {}, was {}".format(*valid_temperature_range, temp) + "Temperature should be between {} and {}, was {}".format( + *valid_temperature_range, temp + ) ) light_state = {"color_temp": temp} diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 7463c6db..3e9bd953 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -101,8 +101,8 @@ def _parse_features(features: str) -> Set[str]: class SmartDevice: """Base class for all supported device types. - You don't usually want to construct this class which implements the shared common interfaces. - The recommended way is to either use the discovery functionality, or construct one of the subclasses: + You don't usually want to initialize this class manually, + but either use :class:`Discover` class, or use one of the subclasses: * :class:`SmartPlug` * :class:`SmartBulb` @@ -145,7 +145,8 @@ class SmartDevice: >>> dev.mac 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 False @@ -154,7 +155,8 @@ class SmartDevice: >>> dev.is_plug 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 {'sw_ver': '1.2.5 Build 171213 Rel.101523', @@ -175,7 +177,8 @@ class SmartDevice: >>> dev.is_on 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 True @@ -184,7 +187,8 @@ class SmartDevice: >>> dev.emeter_today >>> 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)) {11: 1.089, 12: 1.582} @@ -214,9 +218,9 @@ class SmartDevice: self.credentials = credentials _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown - # TODO: typing Any is just as using Optional[Dict] would require separate checks in - # accessors. the @updated_required decorator does not ensure mypy that these - # are not accessed incorrectly. + # TODO: typing Any is just as using Optional[Dict] would require separate + # checks in accessors. the @updated_required decorator does not ensure + # mypy that these are not accessed incorrectly. self._last_update: Any = None self._sys_info: Any = None # TODO: this is here to avoid changing tests self._features: Set[str] = set() @@ -230,8 +234,6 @@ class SmartDevice: _LOGGER.debug("Module %s already registered, ignoring..." % name) return - assert name not in self.modules - _LOGGER.debug("Adding module %s", module) self.modules[name] = module @@ -751,4 +753,8 @@ class SmartDevice: def __repr__(self): if self._last_update is None: 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}>" + ) diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index a412021c..7980319c 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -41,7 +41,8 @@ class SmartDimmer(SmartPlug): This will allow accessing the properties using the exposed properties. 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, 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) 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 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("ambient", AmbientLight(self, "smartlife.iot.LAS")) @@ -187,7 +188,8 @@ class SmartDimmer(SmartPlug): """Set action to perform on button click/hold. :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 """ action_type_setter = f"set_{action_type}" diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index e3dfc15f..2990e1fa 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -11,10 +11,10 @@ class SmartLightStrip(SmartBulb): """Representation of a TP-Link Smart light strip. Light strips work similarly to bulbs, but use a different service for controlling, - and expose some extra information (such as length and active effect). - This class extends :class:`SmartBulb` interface. + and expose some extra information (such as length and active effect). + This class extends :class:`SmartBulb` interface. - Examples: + Examples: >>> import asyncio >>> strip = SmartLightStrip("127.0.0.1") >>> asyncio.run(strip.update()) @@ -105,9 +105,11 @@ class SmartLightStrip(SmartBulb): ) -> None: """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 int brightness: The wanted brightness diff --git a/kasa/smartplug.py b/kasa/smartplug.py index cd323c8d..4ba230b4 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -16,7 +16,8 @@ class SmartPlug(SmartDevice): This will allow accessing the properties using the exposed properties. 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, and should be handled by the user of the library. diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index a02d2f89..80aa27d1 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -40,7 +40,8 @@ class SmartStrip(SmartDevice): This will allow accessing the properties using the exposed properties. 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, and should be handled by the user of the library. diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 3c2b4e4f..b849d080 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -1,8 +1,16 @@ import logging import re -from voluptuous import Coerce # type: ignore -from voluptuous import REMOVE_EXTRA, All, Any, Invalid, Optional, Range, Schema +from voluptuous import ( + REMOVE_EXTRA, + All, + Any, + Coerce, # type: ignore + Invalid, + Optional, + Range, + Schema, +) from ..protocol import TPLinkSmartHomeProtocol @@ -305,7 +313,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): 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) if child_ids: for child in self.proto["system"]["get_sysinfo"]["children"]: @@ -314,7 +324,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): else: 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"]) 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) new_state = light_state - if state_changes["on_off"] == 1: # turn on requested - if not light_state[ - "on_off" - ]: # if we were off, use the dft_on_state as a base - _LOGGER.debug("Bulb was off, using dft_on_state") - new_state = light_state["dft_on_state"] + # turn on requested, if we were off, use the dft_on_state as a base + if state_changes["on_off"] == 1 and not light_state["on_off"]: + _LOGGER.debug("Bulb was off, using dft_on_state") + new_state = light_state["dft_on_state"] # override the existing settings new_state.update(state_changes) @@ -384,7 +394,7 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): self.proto["system"]["get_sysinfo"]["light_state"] = new_state def set_preferred_state(self, new_state, *args): - """Implementation of set_preferred_state.""" + """Implement set_preferred_state.""" self.proto["system"]["get_sysinfo"]["preferred_state"][ new_state["index"] ] = new_state @@ -459,11 +469,11 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): child_ids = [] def get_response_for_module(target): - if target not in proto.keys(): + if target not in proto: return error(msg="target not found") 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") params = request[target][cmd] diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 7f6fa0f2..009632d7 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -156,7 +156,7 @@ async def test_credentials(discovery_data: dict, mocker): mocker.patch("kasa.cli.state", new=_state) # Get the type string parameter from the discovery_info - for cli_device_type in { + for cli_device_type in { # noqa: B007 i for i in TYPE_TO_CLASS if TYPE_TO_CLASS[i] == Discover._get_device_class(discovery_data) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 2aa10f1c..3039f30c 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -71,7 +71,7 @@ async def test_discover_single(discovery_data: dict, mocker, custom_port): x = await Discover.discover_single(host, port=custom_port) assert issubclass(x.__class__, SmartDevice) 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]) @@ -82,7 +82,7 @@ async def test_connect_single(discovery_data: dict, mocker, custom_port): dev = await Discover.connect_single(host, port=custom_port) 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): diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index aeb1fe03..f6f470b8 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -28,9 +28,10 @@ async def test_state_info(dev): @pytest.mark.requires_dummy async def test_invalid_connection(dev): - with patch.object(FakeTransportProtocol, "query", side_effect=SmartDeviceException): - with pytest.raises(SmartDeviceException): - await dev.update() + with patch.object( + FakeTransportProtocol, "query", side_effect=SmartDeviceException + ), pytest.raises(SmartDeviceException): + await dev.update() @has_emeter diff --git a/pyproject.toml b/pyproject.toml index f8adeeed..b6604f3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,12 +54,6 @@ coverage = {version = "*", extras = ["toml"]} docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] speedups = ["orjson", "kasa-crypt"] - -[tool.isort] -profile = "black" -known_first_party = "kasa" -known_third_party = ["asyncclick", "pytest", "setuptools", "voluptuous"] - [tool.coverage.run] source = ["kasa"] branch = true @@ -72,15 +66,6 @@ exclude_lines = [ "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] markers = [ "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] requires = ["poetry-core>=1.0.0"] 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", +] diff --git a/tox.ini b/tox.ini index 7cc957ed..5843142c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist=py37,py38,flake8,lint,coverage +envlist=py37,py38,lint,coverage skip_missing_interpreters = True isolated_build = True @@ -31,12 +31,6 @@ commands = coverage report coverage html -[testenv:flake8] -deps= - flake8 - flake8-docstrings -commands=flake8 kasa - [testenv:lint] deps = pre-commit skip_install = true