diff --git a/.github/update-services.py b/.github/update-services.py index df4c7b30..ee001beb 100644 --- a/.github/update-services.py +++ b/.github/update-services.py @@ -1,5 +1,6 @@ -from pathlib import Path +"""Creates a services.yaml file with the latest docs.""" import sys +from pathlib import Path import yaml @@ -7,8 +8,8 @@ sys.path.append(str(Path(__file__).parent.parent)) from custom_components.adaptive_lighting import const # noqa: E402 -services_filename = "custom_components/adaptive_lighting/services.yaml" -with open(services_filename) as f: +services_filename = Path("custom_components") / "adaptive_lighting" / "services.yaml" +with open(services_filename) as f: # noqa: PTH123 services = yaml.safe_load(f) for service_name, dct in services.items(): @@ -20,6 +21,6 @@ for service_name, dct in services.items(): comment = "# This file is auto-generated by .github/update-services.py." -with open(services_filename, "w") as f: +with services_filename.open("w") as f: f.write(comment + "\n") yaml.dump(services, f, sort_keys=False, width=1000, allow_unicode=True) diff --git a/.github/update-strings.py b/.github/update-strings.py index aabc7443..8f8f9ef4 100644 --- a/.github/update-strings.py +++ b/.github/update-strings.py @@ -1,31 +1,33 @@ +"""Update strings.json and en.json from const.py.""" import json -from pathlib import Path import sys +from pathlib import Path sys.path.append(str(Path(__file__).parent.parent)) from custom_components.adaptive_lighting import const # noqa: E402 -strings_fname = "custom_components/adaptive_lighting/strings.json" -en_fname = "custom_components/adaptive_lighting/translations/en.json" -with open(strings_fname) as f: +folder = Path("custom_components") / "adaptive_lighting" +strings_fname = folder / "strings.json" +en_fname = folder / "translations" / "en.json" +with strings_fname.open() as f: strings = json.load(f) data = {k: f"{k}: {const.DOCS[k]}" for k, _, _ in const.VALIDATION_TUPLES} strings["options"]["step"]["init"]["data"] = data -with open(strings_fname, "w") as f: +with strings_fname.open("w") as f: json.dump(strings, f, indent=2, ensure_ascii=False) f.write("\n") # Sync changes from strings.json to en.json -with open(en_fname) as f: +with en_fname.open() as f: en = json.load(f) en["config"]["step"]["user"] = strings["config"]["step"]["user"] en["options"]["step"]["init"]["data"] = data -with open(en_fname, "w") as f: +with en_fname.open("w") as f: json.dump(en, f, indent=2, ensure_ascii=False) f.write("\n") diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c35e268..6418f6a3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,20 +7,12 @@ repos: - id: end-of-file-fixer - id: mixed-line-ending args: ["--fix=lf"] - - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.279 hooks: - - id: flake8 + - id: ruff + args: ["--fix"] - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black - - repo: https://github.com/asottile/pyupgrade - rev: v3.7.0 - hooks: - - id: pyupgrade - args: ["--py39-plus"] - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 - hooks: - - id: isort diff --git a/.ruff.toml b/.ruff.toml index 260b1883..c1b6b784 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -2,41 +2,30 @@ target-version = "py310" -select = [ - "B007", # Loop control variable {name} not used within loop body - "B014", # Exception handler with duplicate exception - "C", # complexity - "D", # docstrings - "E", # pycodestyle - "F", # pyflakes/autoflake - "ICN001", # import concentions; {name} should be imported as {asname} - "PGH004", # Use specific rule codes when using noqa - "PLC0414", # Useless import alias. Import alias does not rename original package. - "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass - "SIM117", # Merge with-statements that use the same scope - "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() - "SIM201", # Use {left} != {right} instead of not {left} == {right} - "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} - "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. - "SIM401", # Use get from dict with default instead of an if block - "T20", # flake8-print - "TRY004", # Prefer TypeError exception for invalid type - "RUF006", # Store a reference to the return value of asyncio.create_task - "UP", # pyupgrade - "W", # pycodestyle +select = ["ALL"] + +# All the ones without a comment were the ones that are currently violated +# by the codebase. The plan is to fix them all (when sensible) and then enable them. +ignore = [ + "ANN", + "ANN101", # Missing type annotation for {name} in method + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name} + "D401", # First line of docstring should be in imperative mood + "E501", # line too long + "FBT001", # Boolean positional arg in function definition + "FBT002", # Boolean default value in function definition + "FIX004", # Line contains HACK, consider resolving the issue + "PD901", # df is a bad variable name. Be kinder to your future self. + "PERF203",# `try`-`except` within a loop incurs performance overhead + "PLR0913", # Too many arguments to function call (N > 5) + "PLR2004", # Magic value used in comparison, consider replacing X with a constant variable + "S101", # Use of assert detected + "SLF001", # Private member accessed ] -ignore = [ - "D202", # No blank lines allowed after function docstring - "D203", # 1 blank line required before class docstring - "D213", # Multi-line docstring summary should start at the second line - "D404", # First word of the docstring should not be This - "D406", # Section name should end with a newline - "D407", # Section name underlining - "D411", # Missing blank line before section - "E501", # line too long - "E731", # do not assign a lambda expression, use a def -] +[per-file-ignores] +"tests/*.py" = ["ALL"] +".github/*py" = ["INP001"] [flake8-pytest-style] fixture-parentheses = false diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py old mode 100755 new mode 100644 index c4187fa7..98c2e94f --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -2,11 +2,11 @@ import logging from typing import Any +import homeassistant.helpers.config_validation as cv +import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -import voluptuous as vol from .const import ( _DOMAIN_SCHEMA, @@ -35,20 +35,21 @@ CONFIG_SCHEMA = vol.Schema( ) -async def reload_configuration_yaml(event: dict, hass: HomeAssistant): +async def reload_configuration_yaml(event: dict, hass: HomeAssistant): # noqa: ARG001 """Reload configuration.yaml.""" await hass.services.async_call("homeassistant", "check_config", {}) async def async_setup(hass: HomeAssistant, config: dict[str, Any]): """Import integration from config.""" - if DOMAIN in config: for entry in config[DOMAIN]: hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=entry - ) + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=entry, + ), ) return True @@ -65,7 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): data[config_entry.entry_id] = {UNDO_UPDATE_LISTENER: undo_listener} for platform in PLATFORMS: hass.async_create_task( - hass.config_entries.async_forward_entry_setup(config_entry, platform) + hass.config_entries.async_forward_entry_setup(config_entry, platform), ) return True @@ -79,7 +80,8 @@ async def async_update_options(hass, config_entry: ConfigEntry): async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_forward_entry_unload( - config_entry, "switch" + config_entry, + "switch", ) data = hass.data[DOMAIN] data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() diff --git a/custom_components/adaptive_lighting/_docs_helpers.py b/custom_components/adaptive_lighting/_docs_helpers.py index 40afc235..31225a6c 100644 --- a/custom_components/adaptive_lighting/_docs_helpers.py +++ b/custom_components/adaptive_lighting/_docs_helpers.py @@ -1,9 +1,9 @@ from typing import Any -from homeassistant.helpers import selector import homeassistant.helpers.config_validation as cv import pandas as pd import voluptuous as vol +from homeassistant.helpers import selector from .const import ( DOCS, @@ -23,38 +23,37 @@ def _format_voluptuous_instance(instance): for validator in instance.validators: if isinstance(validator, vol.Coerce): coerce_type = validator.type.__name__ - elif isinstance(validator, (vol.Clamp, vol.Range)): + elif isinstance(validator, vol.Clamp | vol.Range): min_val = validator.min max_val = validator.max if min_val is not None and max_val is not None: return f"`{coerce_type}` {min_val}-{max_val}" - elif min_val is not None: + if min_val is not None: return f"`{coerce_type} > {min_val}`" - elif max_val is not None: + if max_val is not None: return f"`{coerce_type} < {max_val}`" - else: - return f"`{coerce_type}`" + return f"`{coerce_type}`" -def _type_to_str(type_: Any) -> str: +def _type_to_str(type_: Any) -> str: # noqa: PLR0911 """Convert a (voluptuous) type to a string.""" if type_ == cv.entity_ids: return "list of `entity_id`s" - elif type_ in (bool, int, float, str): + if type_ in (bool, int, float, str): return f"`{type_.__name__}`" - elif type_ == cv.boolean: + if type_ == cv.boolean: return "bool" - elif isinstance(type_, vol.All): + if isinstance(type_, vol.All): return _format_voluptuous_instance(type_) - elif isinstance(type_, vol.In): + if isinstance(type_, vol.In): return f"one of `{type_.container}`" - elif isinstance(type_, selector.SelectSelector): + if isinstance(type_, selector.SelectSelector): return f"one of `{type_.config['options']}`" - elif isinstance(type_, selector.ColorRGBSelector): + if isinstance(type_, selector.ColorRGBSelector): return "RGB color" - else: - raise ValueError(f"Unknown type: {type_}") + msg = f"Unknown type: {type_}" + raise ValueError(msg) def generate_config_markdown_table(): @@ -85,7 +84,8 @@ def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[Any, Any]]: def _generate_service_markdown_table( - schema: dict[str, tuple[Any, Any]], alternative_docs: dict[str, str] = None + schema: dict[str, tuple[Any, Any]], + alternative_docs: dict[str, str] | None = None, ): schema = _schema_to_dict(schema) rows = [] @@ -112,5 +112,6 @@ def generate_apply_markdown_table(): def generate_set_manual_control_markdown_table(): return _generate_service_markdown_table( - SET_MANUAL_CONTROL_SCHEMA, DOCS_MANUAL_CONTROL + SET_MANUAL_CONTROL_SCHEMA, + DOCS_MANUAL_CONTROL, ) diff --git a/custom_components/adaptive_lighting/adaptation_utils.py b/custom_components/adaptive_lighting/adaptation_utils.py index 374defd1..99d1eea5 100644 --- a/custom_components/adaptive_lighting/adaptation_utils.py +++ b/custom_components/adaptive_lighting/adaptation_utils.py @@ -1,7 +1,7 @@ """Utility functions for adaptation commands.""" +import logging from collections.abc import AsyncGenerator from dataclasses import dataclass -import logging from typing import Any, Literal from homeassistant.components.light import ( @@ -40,10 +40,10 @@ ServiceData = dict[str, Any] def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]: - """Splits the service data by the adapted attributes, i.e., into separate data - items for brightness and color. - """ + """Splits the service data by the adapted attributes. + i.e., into separate data items for brightness and color. + """ common_attrs = {ATTR_ENTITY_ID} common_data = {k: service_data[k] for k in common_attrs if k in service_data} @@ -70,13 +70,14 @@ def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]: def _remove_redundant_attributes( - service_data: ServiceData, state: State + service_data: ServiceData, + state: State, ) -> ServiceData: """Filter service data by removing attributes that already equal the given state. Removes all attributes from service call data whose values are already present - in the target entity's state.""" - + in the target entity's state. + """ return { k: v for k, v in service_data.items() @@ -88,7 +89,8 @@ def _has_relevant_service_data_attributes(service_data: ServiceData) -> bool: """Determines whether the service data justifies an adaptation service call. A service call is not justified for data which does not contain any entries that - change relevant attributes of an adapting entity, e.g., brightness or color.""" + change relevant attributes of an adapting entity, e.g., brightness or color. + """ common_attrs = {ATTR_ENTITY_ID, ATTR_TRANSITION} return any(attr not in common_attrs for attr in service_data) @@ -108,15 +110,15 @@ async def _create_service_call_data_iterator( at the time when the service data is read instead of up front. This gives greater flexibility because entity states can change while the items are iterated. """ - for service_data in service_datas: if filter_by_state and (entity_id := service_data.get(ATTR_ENTITY_ID)): current_entity_state = hass.states.get(entity_id) # Filter data to remove attributes that equal the current state if current_entity_state is not None: - service_data = _remove_redundant_attributes( - service_data, current_entity_state + service_data = _remove_redundant_attributes( # noqa: PLW2901 + service_data, + state=current_entity_state, ) # Emit service data if it still contains relevant attributes (else try next) @@ -143,7 +145,7 @@ class AdaptationData: return await anext(self.service_call_datas, None) -class NoColorOrBrightnessInServiceData(Exception): +class NoColorOrBrightnessInServiceDataError(Exception): """Exception raised when no color or brightness attributes are found in service data.""" @@ -160,7 +162,7 @@ def _identify_lighting_type( if has_color: return "color" msg = f"Invalid service_data, no brightness or color attributes found: {service_data=}" - raise NoColorOrBrightnessInServiceData(msg) + raise NoColorOrBrightnessInServiceDataError(msg) def prepare_adaptation_data( @@ -179,10 +181,7 @@ def prepare_adaptation_data( entity_id, service_data, ) - if split: - service_datas = _split_service_call_data(service_data) - else: - service_datas = [service_data] + service_datas = _split_service_call_data(service_data) if split else [service_data] service_datas_length = len(service_datas) @@ -193,7 +192,9 @@ def prepare_adaptation_data( sleep_time = split_delay service_data_iterator = _create_service_call_data_iterator( - hass, service_datas, filter_by_state + hass, + service_datas, + filter_by_state, ) lighting_type = _identify_lighting_type(service_data) diff --git a/custom_components/adaptive_lighting/config_flow.py b/custom_components/adaptive_lighting/config_flow.py index 10ba5d86..170c8503 100644 --- a/custom_components/adaptive_lighting/config_flow.py +++ b/custom_components/adaptive_lighting/config_flow.py @@ -1,11 +1,11 @@ """Config flow for Adaptive Lighting integration.""" import logging +import homeassistant.helpers.config_validation as cv +import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_NAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv -import voluptuous as vol from .const import ( # pylint: disable=unused-import CONF_LIGHTS, @@ -75,7 +75,7 @@ def validate_options(user_input, errors): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for Adaptive Lighting.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry @@ -114,5 +114,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): options_schema[key] = value return self.async_show_form( - step_id="init", data_schema=vol.Schema(options_schema), errors=errors + step_id="init", + data_schema=vol.Schema(options_schema), + errors=errors, ) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 15ce097b..64b96583 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -1,10 +1,10 @@ """Constants for the Adaptive Lighting integration.""" +import homeassistant.helpers.config_validation as cv +import voluptuous as vol from homeassistant.components.light import VALID_TRANSITION from homeassistant.const import CONF_ENTITY_ID from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv -import voluptuous as vol ICON_MAIN = "mdi:theme-light-dark" ICON_BRIGHTNESS = "mdi:brightness-4" @@ -49,9 +49,9 @@ DOCS[CONF_INITIAL_TRANSITION] = ( ) CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1 -DOCS[CONF_SLEEP_TRANSITION] = ( - 'Duration of transition when "sleep mode" is toggled ' "in seconds. 😴" -) +DOCS[ + CONF_SLEEP_TRANSITION +] = 'Duration of transition when "sleep mode" is toggled in seconds. 😴' CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90 DOCS[CONF_INTERVAL] = "Frequency to adapt the lights, in seconds. 🔄" @@ -99,22 +99,22 @@ DOCS[CONF_SLEEP_COLOR_TEMP] = ( ) CONF_SLEEP_RGB_COLOR, DEFAULT_SLEEP_RGB_COLOR = "sleep_rgb_color", [255, 56, 0] -DOCS[CONF_SLEEP_RGB_COLOR] = ( - "RGB color in sleep mode (used when " '`sleep_rgb_or_color_temp` is "rgb_color"). 🌈' -) +DOCS[ + CONF_SLEEP_RGB_COLOR +] = 'RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈' CONF_SLEEP_RGB_OR_COLOR_TEMP, DEFAULT_SLEEP_RGB_OR_COLOR_TEMP = ( "sleep_rgb_or_color_temp", "color_temp", ) -DOCS[CONF_SLEEP_RGB_OR_COLOR_TEMP] = ( - 'Use either `"rgb_color"` or `"color_temp"` ' "in sleep mode. 🌙" -) +DOCS[ + CONF_SLEEP_RGB_OR_COLOR_TEMP +] = 'Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙' CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0 -DOCS[CONF_SUNRISE_OFFSET] = ( - "Adjust sunrise time with a positive or negative offset " "in seconds. ⏰" -) +DOCS[ + CONF_SUNRISE_OFFSET +] = "Adjust sunrise time with a positive or negative offset in seconds. ⏰" CONF_SUNRISE_TIME = "sunrise_time" DOCS[CONF_SUNRISE_TIME] = "Set a fixed time (HH:MM:SS) for sunrise. 🌅" @@ -333,7 +333,7 @@ _DOMAIN_SCHEMA = vol.Schema( { vol.Optional(key, default=replace_none_str(default, vol.UNDEFINED)): validation for key, default, validation in _yaml_validation_tuples - } + }, ) @@ -351,7 +351,7 @@ def apply_service_schema(initial_transition: int = 1): vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, - } + }, ) @@ -360,5 +360,5 @@ SET_MANUAL_CONTROL_SCHEMA = vol.Schema( vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, - } + }, ) diff --git a/custom_components/adaptive_lighting/hass_utils.py b/custom_components/adaptive_lighting/hass_utils.py index 5a195bcc..3f08b3fc 100644 --- a/custom_components/adaptive_lighting/hass_utils.py +++ b/custom_components/adaptive_lighting/hass_utils.py @@ -1,6 +1,5 @@ """Utility functions for HA core.""" -from collections.abc import Awaitable -from typing import Callable +from collections.abc import Awaitable, Callable from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.util.read_only_dict import ReadOnlyDict @@ -17,7 +16,8 @@ def setup_service_call_interceptor( """Inject a function into a registered service call to preprocess service data. The injected interceptor function receives the service call and a writeable data dictionary - (the data of the service call is read-only) before the service call is executed.""" + (the data of the service call is read-only) before the service call is executed. + """ try: # HACK: Access protected attribute of HA service registry. # This is necessary to replace a registered service handler with our @@ -26,15 +26,15 @@ def setup_service_call_interceptor( hass.services._services # pylint: disable=protected-access ) except AttributeError as error: - raise RuntimeError( - "Intercept failed because registered services are no longer accessible " - "(internal API may have changed)" - ) from error + msg = ( + "Intercept failed because registered services are no longer" + " accessible (internal API may have changed)" + ) + raise RuntimeError(msg) from error if domain not in registered_services or service not in registered_services[domain]: - raise RuntimeError( - f"Intercept failed because service {domain}.{service} is not registered" - ) + msg = f"Intercept failed because service {domain}.{service} is not registered" + raise RuntimeError(msg) existing_service = registered_services[domain][service] @@ -52,13 +52,19 @@ def setup_service_call_interceptor( await existing_service.job.target(call) hass.services.async_register( - domain, service, service_func_proxy, existing_service.schema + domain, + service, + service_func_proxy, + existing_service.schema, ) def remove(): # Remove the interceptor by reinstalling the original service handler hass.services.async_register( - domain, service, existing_service.job.target, existing_service.schema + domain, + service, + existing_service.job.target, + existing_service.schema, ) return remove diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 58fa3eaa..d42cccae 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -4,17 +4,19 @@ from __future__ import annotations import asyncio import base64 import bisect -from collections.abc import Callable, Coroutine, Iterable -from copy import deepcopy -from dataclasses import dataclass import datetime -from datetime import timedelta import functools import logging import math -from typing import Any, Literal +from copy import deepcopy +from dataclasses import dataclass +from datetime import timedelta +from typing import TYPE_CHECKING, Any, Literal -import astral +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util +import ulid_transform +import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -29,8 +31,6 @@ from homeassistant.components.light import ( COLOR_MODE_RGBW, COLOR_MODE_RGBWW, COLOR_MODE_XY, -) -from homeassistant.components.light import ( SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, @@ -41,7 +41,6 @@ from homeassistant.components.light import ( from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_AREA_ID, ATTR_DOMAIN, @@ -71,7 +70,6 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_platform, entity_registry -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( async_track_state_change_event, async_track_time_interval, @@ -86,9 +84,6 @@ from homeassistant.util.color import ( color_xy_to_hs, color_xy_to_RGB, ) -import homeassistant.util.dt as dt_util -import ulid_transform -import voluptuous as vol from .adaptation_utils import ( BRIGHTNESS_ATTRS, @@ -156,6 +151,13 @@ from .const import ( ) from .hass_utils import setup_service_call_interceptor +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine, Iterable + + import astral + from homeassistant.config_entries import ConfigEntry + from homeassistant.helpers.entity_platform import AddEntitiesCallback + _SUPPORT_OPTS = { "brightness": SUPPORT_BRIGHTNESS, "color_temp": SUPPORT_COLOR_TEMP, @@ -209,17 +211,17 @@ def _int_to_base36(num: int) -> str: >>> print(base36_num) '2N9' """ - ALPHANUMERIC_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + alphanumeric_chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" if num == 0: - return ALPHANUMERIC_CHARS[0] + return alphanumeric_chars[0] base36_str = "" - base = len(ALPHANUMERIC_CHARS) + base = len(alphanumeric_chars) while num: num, remainder = divmod(num, base) - base36_str = ALPHANUMERIC_CHARS[remainder] + base36_str + base36_str = alphanumeric_chars[remainder] + base36_str return base36_str @@ -236,7 +238,10 @@ def _remove_vowels(input_str: str, length: int = 4) -> str: def create_context( - name: str, which: str, index: int, parent: Context | None = None + name: str, + which: str, + index: int, + parent: Context | None = None, ) -> Context: """Create a context that can identify this integration.""" # Use a hash for the name because otherwise the context might become @@ -255,6 +260,7 @@ def create_context( def is_our_context_id(context_id: str | None) -> bool: + """Check whether this integration created 'context_id'.""" if context_id is None: return False return f":{_DOMAIN_SHORT}:" in context_id @@ -268,7 +274,8 @@ def is_our_context(context: Context | None) -> bool: def _switches_with_lights( - hass: HomeAssistant, lights: list[str] + hass: HomeAssistant, + lights: list[str], ) -> list[AdaptiveSwitch]: """Get all switches that control at least one of the lights passed.""" config_entries = hass.config_entries.async_entries(DOMAIN) @@ -299,48 +306,52 @@ def _switch_with_lights( switches = _switches_with_lights(hass, lights) if len(switches) == 1: return switches[0] - elif len(switches) > 1: + if len(switches) > 1: on_switches = [s for s in switches if s.is_on] if len(on_switches) == 1: # Of the multiple switches, only one is on return on_switches[0] - raise NoSwitchFoundError( + msg = ( f"_switch_with_lights: Light(s) {lights} found in multiple switch configs" f" ({[s.entity_id for s in switches]}). You must pass a switch under" - f" 'entity_id'." - ) - else: - raise NoSwitchFoundError( - f"_switch_with_lights: Light(s) {lights} not found in any switch's" - f" configuration. You must either include the light(s) that is/are" - f" in the integration config, or pass a switch under 'entity_id'." + " 'entity_id'." ) + raise NoSwitchFoundError(msg) + msg = ( + f"_switch_with_lights: Light(s) {lights} not found in any switch's" + " configuration. You must either include the light(s) that is/are" + " in the integration config, or pass a switch under 'entity_id'." + ) + raise NoSwitchFoundError(msg) # For documentation on this function, see integration_entities() from HomeAssistant Core: # https://github.com/home-assistant/core/blob/dev/homeassistant/helpers/template.py#L1109 def _switches_from_service_call( - hass: HomeAssistant, service_call: ServiceCall + hass: HomeAssistant, + service_call: ServiceCall, ) -> list[AdaptiveSwitch]: data = service_call.data lights = data[CONF_LIGHTS] switch_entity_ids: list[str] | None = data.get("entity_id") if not lights and not switch_entity_ids: - raise ValueError( + msg = ( "adaptive-lighting: Neither a switch nor a light was provided in the service call." - " If you intend to adapt all lights on all switches, please inform the developers at" - " https://github.com/basnijholt/adaptive-lighting about your use case." - " Currently, you must pass either an adaptive-lighting switch or the lights to an" - " `adaptive_lighting` service call." + " If you intend to adapt all lights on all switches, please inform the" + " developers at https://github.com/basnijholt/adaptive-lighting about your" + " use case. Currently, you must pass either an adaptive-lighting switch or" + " the lights to an `adaptive_lighting` service call." ) + raise ValueError(msg) if switch_entity_ids is not None: if len(switch_entity_ids) > 1 and lights: - raise ValueError( - f"adaptive-lighting: Cannot pass multiple switches with lights argument." + msg = ( + "adaptive-lighting: Cannot pass multiple switches with lights argument." f" Invalid service data received: {service_call.data}" ) + raise ValueError(msg) switches = [] ent_reg = entity_registry.async_get(hass) for entity_id in switch_entity_ids: @@ -353,14 +364,16 @@ def _switches_from_service_call( switch = _switch_with_lights(hass, lights) return [switch] - raise ValueError( - f"adaptive-lighting: Incorrect data provided in service call." + msg = ( + "adaptive-lighting: Incorrect data provided in service call." f" Entities not found in the integration. Service data: {service_call.data}" ) + raise ValueError(msg) async def handle_change_switch_settings( - switch: AdaptiveSwitch, service_call: ServiceCall + switch: AdaptiveSwitch, + service_call: ServiceCall, ) -> None: """Allows HASS to change config values via a service call.""" data = service_call.data @@ -396,7 +409,10 @@ async def handle_change_switch_settings( @callback def _fire_manual_control_event( - switch: AdaptiveSwitch, light: str, context: Context, is_async=True + switch: AdaptiveSwitch, + light: str, + context: Context, + is_async: bool = True, ): """Fire an event that 'light' is marked as manual_control.""" hass = switch.hass @@ -414,8 +430,10 @@ def _fire_manual_control_event( ) -async def async_setup_entry( - hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: bool +async def async_setup_entry( # noqa: PLR0915 + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ): """Set up the AdaptiveLighting switch.""" data = hass.data[DOMAIN] @@ -423,17 +441,30 @@ async def async_setup_entry( if ATTR_ADAPTIVE_LIGHTING_MANAGER not in data: data[ATTR_ADAPTIVE_LIGHTING_MANAGER] = AdaptiveLightingManager( - hass, config_entry + hass, + config_entry, ) manager: AdaptiveLightingManager = data[ATTR_ADAPTIVE_LIGHTING_MANAGER] sleep_mode_switch = SimpleSwitch( - "Sleep Mode", False, hass, config_entry, ICON_SLEEP + which="Sleep Mode", + initial_state=False, + hass=hass, + config_entry=config_entry, + icon=ICON_SLEEP, ) adapt_color_switch = SimpleSwitch( - "Adapt Color", True, hass, config_entry, ICON_COLOR_TEMP + which="Adapt Color", + initial_state=True, + hass=hass, + config_entry=config_entry, + icon=ICON_COLOR_TEMP, ) adapt_brightness_switch = SimpleSwitch( - "Adapt Brightness", True, hass, config_entry, ICON_BRIGHTNESS + which="Adapt Brightness", + initial_state=True, + hass=hass, + config_entry=config_entry, + icon=ICON_BRIGHTNESS, ) switch = AdaptiveSwitch( hass, @@ -482,7 +513,8 @@ async def async_setup_entry( data[ATTR_ADAPT_COLOR], data[CONF_PREFER_RGB_COLOR], context=switch.create_context( - "service", parent=service_call.context + "service", + parent=service_call.context, ), ) @@ -513,7 +545,8 @@ async def async_setup_entry( transition=switch.initial_transition, force=True, context=switch.create_context( - "service", parent=service_call.context + "service", + parent=service_call.context, ), ) @@ -637,7 +670,8 @@ def _supported_features(hass: HomeAssistant, light: str) -> set[str]: def color_difference_redmean( - rgb1: tuple[float, float, float], rgb2: tuple[float, float, float] + rgb1: tuple[float, float, float], + rgb2: tuple[float, float, float], ) -> float: """Distance between colors in RGB space (redmean metric). @@ -648,7 +682,9 @@ def color_difference_redmean( - https://www.compuphase.com/cmetric.htm """ r_hat = (rgb1[0] + rgb2[0]) / 2 - delta_r, delta_g, delta_b = ((col1 - col2) for col1, col2 in zip(rgb1, rgb2)) + delta_r, delta_g, delta_b = ( + (col1 - col2) for col1, col2 in zip(rgb1, rgb2, strict=True) + ) red_term = (2 + r_hat / 256) * delta_r**2 green_term = 4 * delta_g**2 blue_term = (2 + (255 - r_hat) / 256) * delta_b**2 @@ -700,7 +736,8 @@ def _attributes_have_changed( ) -> bool: if adapt_color: old_attributes, new_attributes = _add_missing_attributes( - old_attributes, new_attributes + old_attributes, + new_attributes, ) if ( @@ -771,7 +808,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): sleep_mode_switch: SimpleSwitch, adapt_color_switch: SimpleSwitch, adapt_brightness_switch: SimpleSwitch, - ): + ) -> None: """Initialize the Adaptive Lighting switch.""" # Set attributes that can't be modified during runtime self.hass = hass @@ -920,7 +957,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): await self._setup_listeners() else: self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, self._setup_listeners + EVENT_HOMEASSISTANT_STARTED, + self._setup_listeners, ) last_state = await self.async_get_last_state() is_new_entry = last_state is None # newly added to HA @@ -938,7 +976,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): all_lights = _expand_light_groups(self.hass, self.lights) self.manager.lights.update(all_lights) self.manager.set_auto_reset_manual_control_times( - all_lights, self._auto_reset_manual_control_time + all_lights, + self._auto_reset_manual_control_time, ) self.lights = list(all_lights) @@ -963,7 +1002,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if self.lights: self._expand_light_groups() remove_state = async_track_state_change_event( - self.hass, entity_ids=self.lights, action=self._light_event_action + self.hass, + entity_ids=self.lights, + action=self._light_event_action, ) self.remove_listeners.append(remove_state) @@ -1029,7 +1070,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): return extra_state_attributes def create_context( - self, which: str = "default", parent: Context | None = None + self, + which: str = "default", + parent: Context | None = None, ) -> Context: """Create a context that identifies this Adaptive Lighting instance.""" context = create_context(self._name, which, self._context_cnt, parent=parent) @@ -1037,11 +1080,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): return context async def async_turn_on( # pylint: disable=arguments-differ - self, adapt_lights: bool = True + self, + adapt_lights: bool = True, ) -> None: """Turn on adaptive lighting.""" _LOGGER.debug( - "%s: Called 'async_turn_on', current state is '%s'", self._name, self._state + "%s: Called 'async_turn_on', current state is '%s'", + self._name, + self._state, ) if self.is_on: return @@ -1055,7 +1101,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context=self.create_context("turn_on"), ) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs) -> None: # noqa: ARG002 """Turn off adaptive lighting.""" if not self.is_on: return @@ -1063,7 +1109,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._remove_listeners() self.manager.reset(*self.lights) - async def _async_update_at_interval_action(self, now=None) -> None: + async def _async_update_at_interval_action(self, now=None) -> None: # noqa: ARG002 await self._update_attrs_and_maybe_adapt_lights( transition=self._transition, force=False, @@ -1079,6 +1125,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): prefer_rgb_color: bool | None = None, context: Context | None = None, ) -> AdaptationData | None: + """Prepare `AdaptationData` for adapting a light.""" if transition is None: transition = self._transition if adapt_brightness is None: @@ -1099,7 +1146,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): # The switch might be off and not have _settings set. self._settings = self._sun_light_settings.get_settings( - self.sleep_mode_switch.is_on, transition + self.sleep_mode_switch.is_on, + transition, ) # Build service data. @@ -1151,7 +1199,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): filter_by_state=self._skip_redundant_commands, ) - async def _adapt_light( # noqa: C901 + async def _adapt_light( self, light: str, transition: int | None = None, @@ -1167,7 +1215,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if self.manager.is_proactively_adapting(context.parent_id): # Skip if adaptation was already executed by the service call interceptor _LOGGER.debug( - "%s: Skipping reactive adaptation of %s", self._name, context.parent_id + "%s: Skipping reactive adaptation of %s", + self._name, + context.parent_id, ) return @@ -1180,13 +1230,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context, ) if data is None: - return None # nothing to adapt + return # nothing to adapt await self.execute_cancellable_adaptation_calls(data) async def _execute_adaptation_calls(self, data: AdaptationData): """Executes a sequence of adaptation service calls for the given service datas.""" - for index in range(data.max_length): is_first_call = index == 0 @@ -1250,7 +1299,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): data, ) - async def _update_attrs_and_maybe_adapt_lights( + async def _update_attrs_and_maybe_adapt_lights( # noqa: PLR0912 self, lights: list[str] | None = None, transition: int | None = None, @@ -1270,8 +1319,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): assert self.is_on self._settings.update( self._sun_light_settings.get_settings( - self.sleep_mode_switch.is_on, transition - ) + self.sleep_mode_switch.is_on, + transition, + ), ) self.async_write_ha_state() @@ -1352,7 +1402,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): _LOGGER.debug("%s: Ignoring sleep event %s", self._name, event) return _LOGGER.debug( - "%s: _sleep_mode_switch_state_event_action, event: '%s'", self._name, event + "%s: _sleep_mode_switch_state_event_action, event: '%s'", + self._name, + event, ) # Reset the manually controlled status when the "sleep mode" changes self.manager.reset(*self.lights) @@ -1380,7 +1432,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) if event.context.parent_id and not self.manager.is_proactively_adapting( - event.context.id + event.context.id, ): self.manager.reset(entity_id, reset_manual_control=False) @@ -1395,7 +1447,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ): # Stop if a rapid 'off' → 'on' → 'off' happens. _LOGGER.debug( - "%s: Cancelling adjusting lights for %s", self._name, entity_id + "%s: Cancelling adjusting lights for %s", + self._name, + entity_id, ) return @@ -1441,7 +1495,7 @@ class SimpleSwitch(SwitchEntity, RestoreEntity): hass: HomeAssistant, config_entry: ConfigEntry, icon: str, - ): + ) -> None: """Initialize the Adaptive Lighting switch.""" self.hass = hass data = validate(config_entry) @@ -1484,19 +1538,21 @@ class SimpleSwitch(SwitchEntity, RestoreEntity): else: await self.async_turn_off() - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs) -> None: # noqa: ARG002 """Turn on adaptive lighting sleep mode.""" _LOGGER.debug("%s: Turning on", self._name) self._state = True - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs) -> None: # noqa: ARG002 """Turn off adaptive lighting sleep mode.""" _LOGGER.debug("%s: Turning off", self._name) self._state = False def lerp_color( - rgb1: tuple[int, int, int], rgb2: tuple[int, int, int], t: float + rgb1: tuple[int, int, int], + rgb2: tuple[int, int, int], + t: float, ) -> tuple[int, int, int]: """Linearly interpolate between two RGB colors.""" return ( @@ -1536,13 +1592,13 @@ class SunLightSettings: def _replace_time(date: datetime.datetime, key: str) -> datetime.datetime: time = getattr(self, f"{key}_time") date_time = datetime.datetime.combine(date, time) - utc_time = date_time.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone( - dt_util.UTC + return date_time.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone( + dt_util.UTC, ) - return utc_time def calculate_noon_and_midnight( - sunset: datetime.datetime, sunrise: datetime.datetime + sunset: datetime.datetime, + sunrise: datetime.datetime, ) -> tuple[datetime.datetime, datetime.datetime]: middle = abs(sunset - sunrise) / 2 if sunset > sunrise: @@ -1597,7 +1653,7 @@ class SunLightSettings: ] # Check whether order is correct events = sorted(events, key=lambda x: x[1]) - events_names, _ = zip(*events) + events_names, _ = zip(*events, strict=True) if events_names not in _ALLOWED_ORDERS: msg = ( f"{self.name}: The sun events {events_names} are not in the expected" @@ -1635,8 +1691,7 @@ class SunLightSettings: else (next_ts, prev_ts) ) k = 1 if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_NOON) else -1 - percentage = (0 - k) * ((target_ts - h) / (h - x)) ** 2 + k - return percentage + return (0 - k) * ((target_ts - h) / (h - x)) ** 2 + k def calc_brightness_pct(self, percent: float, is_sleep: bool) -> float: """Calculate the brightness in %.""" @@ -1660,9 +1715,12 @@ class SunLightSettings: delta = abs(self.min_color_temp - self.sleep_color_temp) ct = (delta * abs(1 + percent)) + self.sleep_color_temp return 5 * round(ct / 5) # round to nearest 5 + return None def get_settings( - self, is_sleep, transition + self, + is_sleep, + transition, ) -> dict[str, float | int | tuple[float, float] | tuple[float, float, float]]: """Get all light settings. @@ -1715,7 +1773,7 @@ class SunLightSettings: class AdaptiveLightingManager: """Track 'light.turn_off' and 'light.turn_on' service calls.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the AdaptiveLightingManager that is shared among all switches.""" self.hass = hass data = validate(config_entry) @@ -1759,7 +1817,8 @@ class AdaptiveLightingManager: self._proactively_adapting_contexts: dict[str, str] = {} is_proactive_adaptation_enabled = data.get( - INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION, True + INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION, + True, ) if is_proactive_adaptation_enabled: @@ -1770,7 +1829,7 @@ class AdaptiveLightingManager: LIGHT_DOMAIN, SERVICE_TURN_ON, self._service_interceptor_turn_on_handler, - ) + ), ) self.listener_removers.append( @@ -1779,7 +1838,7 @@ class AdaptiveLightingManager: LIGHT_DOMAIN, SERVICE_TOGGLE, self._service_interceptor_turn_on_handler, - ) + ), ) _LOGGER.debug("Proactive adaptation enabled") @@ -1796,20 +1855,21 @@ class AdaptiveLightingManager: remove() def set_proactively_adapting(self, context_id: str, entity_id: str) -> None: - """Declare the adaptation with the given context ID as proactively adapting, - and associate it to an entity ID.""" + """Declare the adaptation with context_id as proactively adapting, + and associate it to an entity_id. + """ # noqa: D205 self._proactively_adapting_contexts[context_id] = entity_id def is_proactively_adapting(self, context_id: str) -> bool: - """Determine whether an adaptation with the given context ID is proactive.""" + """Determine whether an adaptation with the given context_id is proactive.""" is_proactively_adapting_context = ( context_id in self._proactively_adapting_contexts ) _LOGGER.debug( - "is_proactively_adapting_context %s %s", - context_id, + "is_proactively_adapting_context='%s', context_id='%s'", is_proactively_adapting_context, + context_id, ) return is_proactively_adapting_context @@ -1817,16 +1877,19 @@ class AdaptiveLightingManager: def clear_proactively_adapting(self, entity_id: str) -> None: """Clear all context IDs associated with the given entity ID. - Call this method to clear past context IDs and avoid a memory leak.""" + Call this method to clear past context IDs and avoid a memory leak. + """ + # First get the keys to avoid modifying the dict while iterating it keys = [ k for k, v in self._proactively_adapting_contexts.items() if v == entity_id ] - for key in keys: self._proactively_adapting_contexts.pop(key) - async def _service_interceptor_turn_on_handler( - self, call: ServiceCall, data: ServiceData + async def _service_interceptor_turn_on_handler( # noqa: PLR0911 + self, + call: ServiceCall, + data: ServiceData, ): # Don't adapt our own service calls if is_our_context(call.context): @@ -1872,14 +1935,17 @@ class AdaptiveLightingManager: return _LOGGER.debug( - "Intercepted TURN_ON call with data %s (%s)", data, call.context.id + "Intercepted TURN_ON call with data %s (%s)", + data, + call.context.id, ) self.reset(entity_id, reset_manual_control=False) self.clear_proactively_adapting(entity_id) transition = data[CONF_PARAMS].get( - ATTR_TRANSITION, adaptive_switch.initial_transition + ATTR_TRANSITION, + adaptive_switch.initial_transition, ) adaptation_data = await adaptive_switch.prepare_adaptation_data( @@ -1913,8 +1979,8 @@ class AdaptiveLightingManager: self.set_proactively_adapting(call.context.id, entity_id) self.set_proactively_adapting(adaptation_data.context.id, entity_id) adaptation_data.initial_sleep = True - asyncio.create_task( # Don't await to avoid blocking the service call - adaptive_switch.execute_cancellable_adaptation_calls(adaptation_data) + _ = asyncio.create_task( # Don't await to avoid blocking the service call + adaptive_switch.execute_cancellable_adaptation_calls(adaptation_data), ) def _handle_timer( @@ -1946,11 +2012,14 @@ class AdaptiveLightingManager: last_transition = last_service_data.get(ATTR_TRANSITION) if not last_transition: _LOGGER.debug( - "No transition in last adapt for light %s, continuing...", light + "No transition in last adapt for light %s, continuing...", + light, ) return _LOGGER.debug( - "Start transition timer of %s seconds for light %s", last_transition, light + "Start transition timer of %s seconds for light %s", + last_transition, + light, ) async def reset(): @@ -2008,7 +2077,9 @@ class AdaptiveLightingManager: self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset) def cancel_ongoing_adaptation_calls( - self, light_id: str, which: Literal["color", "brightness", "both"] = "both" + self, + light_id: str, + which: Literal["color", "brightness", "both"] = "both", ): """Cancel ongoing adaptation service calls for a specific light entity.""" brightness_task = self.adaptation_tasks_brightness.get(light_id) @@ -2054,15 +2125,21 @@ class AdaptiveLightingManager: area_ids = cv.ensure_list_csv(service_data[ATTR_AREA_ID]) for area_id in area_ids: area_entity_ids = area_entities(self.hass, area_id) - for entity_id in area_entity_ids: - if entity_id.startswith(LIGHT_DOMAIN): - entity_ids.append(entity_id) + eids = [ + entity_id + for entity_id in area_entity_ids + if entity_id.startswith(LIGHT_DOMAIN) + ] + entity_ids.extend(eids) _LOGGER.debug( - "Found entity_ids '%s' for area_id '%s'", entity_ids, area_id + "Found entity_ids '%s' for area_id '%s'", + entity_ids, + area_id, ) else: _LOGGER.debug( - "No entity_ids or area_ids found in service_data: %s", service_data + "No entity_ids or area_ids found in service_data: %s", + service_data, ) return entity_ids @@ -2219,7 +2296,7 @@ class AdaptiveLightingManager: """ last_service_data = self.last_service_data.get(light) if last_service_data is None: - return + return None compare_to = functools.partial( _attributes_have_changed, light=light, @@ -2268,8 +2345,11 @@ class AdaptiveLightingManager: ) return False - async def maybe_cancel_adjusting( - self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None + async def maybe_cancel_adjusting( # noqa: PLR0911, PLR0912 + self, + entity_id: str, + off_to_on_event: Event, + on_to_off_event: Event | None, ) -> bool: """Cancel the adjusting of a light if it has just been turned off. @@ -2368,7 +2448,7 @@ class AdaptiveLightingManager: class _AsyncSingleShotTimer: - def __init__(self, delay, callback): + def __init__(self, delay, callback) -> None: """Initialize the timer.""" self.delay = delay self.callback = callback diff --git a/test_dependencies.py b/test_dependencies.py index 2cf8dfb0..f8fbf5b9 100644 --- a/test_dependencies.py +++ b/test_dependencies.py @@ -1,13 +1,17 @@ +"""Extracts the dependencies of the components required for testing.""" from collections import defaultdict +from pathlib import Path deps = defaultdict(list) components, packages = [], [] -with open("core/requirements_test_all.txt") as f: +requirements = Path("core") / "requirements_test_all.txt" + +with requirements.open() as f: lines = f.readlines() for line in lines: - line = line.strip() + line = line.strip() # noqa: PLW2901 if line.startswith("# homeassistant."): if components and packages: @@ -34,4 +38,4 @@ required = [ ] to_install = [package for r in required for package in deps[r]] -print(" ".join(to_install)) +print(" ".join(to_install)) # noqa: T201