From 6283158ff730644f76d8007c70edbf0ffd6846fe Mon Sep 17 00:00:00 2001 From: Mario Guggenberger Date: Mon, 12 Jun 2023 00:33:07 +0200 Subject: [PATCH] VS Code Dev Container (dev & test environment) (#605) * build: dev container Add a VS Code Dev Container from the blueprint at https://github.com/ludeeus/integration_blueprint/tree/bceaae212fefefae84d9529cde5cb6f4b60cc865 * build: dev container test setup Add support for unit testing in the dev container environment with debugging and code coverage. * ci: adjust to dev container test restructuring * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * ci: fix coverage collection * build: add dummy light to HA config * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * build: update dev container to Python 3.11 (for HA 2023.6) * Add VS Code tasks * Use pre-commit hooks for linting * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Unpin HA version --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt --- .devcontainer.json | 42 ++++++++ .gitattributes | 1 + .github/workflows/pytest.yaml | 10 +- .gitignore | 8 ++ .ruff.toml | 48 +++++++++ .vscode/tasks.json | 17 +++ Dockerfile | 14 ++- config/configuration.yaml | 19 ++++ requirements.txt | 10 ++ scripts/develop | 20 ++++ scripts/lint | 7 ++ scripts/setup | 8 ++ setup.cfg | 4 + tests/README.md | 2 +- tests/conftest.py | 19 ++++ tests/test_config_flow.py | 10 +- tests/test_init.py | 9 +- tests/test_switch.py | 192 ++++++++++++++++++---------------- 18 files changed, 329 insertions(+), 111 deletions(-) create mode 100644 .devcontainer.json create mode 100644 .gitattributes create mode 100644 .ruff.toml create mode 100644 .vscode/tasks.json create mode 100644 config/configuration.yaml create mode 100644 requirements.txt create mode 100644 scripts/develop create mode 100644 scripts/lint create mode 100644 scripts/setup create mode 100644 tests/conftest.py diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 00000000..d842d1dd --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "basnijholt/adaptive_lighting", + "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.11-bullseye", + "postCreateCommand": "scripts/setup", + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/rust:1": {} + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 1b51f257..b5089ded 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -50,11 +50,6 @@ jobs: run: | cd core - # Link homeassitant.components.adaptive_lighting - cd homeassistant/components - ln -fs ../../../custom_components/adaptive_lighting adaptive_lighting - cd - - # Link adaptive_lighting tests cd tests/components/ ln -fs ../../../tests adaptive_lighting @@ -63,14 +58,17 @@ jobs: - name: Run pytest timeout-minutes: 60 run: | + export PYTHONPATH=${PYTHONPATH}:${PWD} cd core python3 -X dev -m pytest \ -vvv \ -qq \ --timeout=9 \ --durations=10 \ - --cov="homeassistant" \ + --cov="custom_components.adaptive_lighting" \ --cov-report=xml \ -o console_output_style=count \ -p no:sugar \ tests/components/adaptive_lighting + env: + HA_CLONE: true diff --git a/.gitignore b/.gitignore index b6e47617..eeef9022 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,11 @@ dmypy.json # Pyre type checker .pyre/ + +# IDEs +.vscode +.idea + +# Home Assistant configuration +config/* +!config/configuration.yaml diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 00000000..260b1883 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,48 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +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 +] + +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 +] + +[flake8-pytest-style] +fixture-parentheses = false + +[pyupgrade] +keep-runtime-typing = true + +[mccabe] +max-complexity = 25 diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..cd2130b2 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 8123", + "type": "shell", + "command": "scripts/develop", + "problemMatcher": [] + }, + { + "label": "Lint (run pre-commit hooks)", + "type": "shell", + "command": "scripts/lint", + "problemMatcher": [] + } + ] +} diff --git a/Dockerfile b/Dockerfile index 45522da8..4baac59a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,12 +23,11 @@ RUN pip3 install -r /core/requirements.txt --use-pep517 && \ pip3 install -r /core/requirements_test.txt --use-pep517 && \ pip3 install -e /core/ --use-pep517 -# Clone the Adaptive Lighting repository -RUN git clone https://github.com/basnijholt/adaptive-lighting.git /app +# Copy the Adaptive Lighting repository +COPY . /app/ # Setup symlinks in core -RUN ln -s /app/custom_components/adaptive_lighting /core/homeassistant/components/adaptive_lighting && \ - ln -s /app/tests /core/tests/components/adaptive_lighting && \ +RUN ln -s /app/tests /core/tests/components/adaptive_lighting && \ # For test_dependencies.py ln -s /core /app/core @@ -37,6 +36,11 @@ RUN pip3 install $(python3 /app/test_dependencies.py) --use-pep517 WORKDIR /core +# Make 'custom_components/adaptive_lighting' imports available to tests +ENV PYTHONPATH="${PYTHONPATH}:/app" +# Enable testing against HA clone (instead of pytest_homeassistant_custom_component) +ENV HA_CLONE=true + ENTRYPOINT ["python3", \ # Enable Python development mode "-X", "dev", \ @@ -49,7 +53,7 @@ ENTRYPOINT ["python3", \ # Print the 10 slowest tests "--durations=10", \ # Measure code coverage for the 'homeassistant' package - "--cov='homeassistant'", \ + "--cov=custom_components.adaptive_lighting", \ # Generate an XML report of the code coverage "--cov-report=xml", \ # Generate an HTML report of the code coverage diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 00000000..b9d7d45b --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,19 @@ +# https://www.home-assistant.io/integrations/default_config/ +default_config: + +# https://www.home-assistant.io/integrations/logger/ +logger: + default: info + logs: + custom_components.adaptive_lighting: debug + +light: + - platform: template + lights: + dummylight: + friendly_name: "Dummy Light" + turn_on: + turn_off: + set_level: + set_temperature: + supports_transition_template: "{{ true }}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..c560a858 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +colorlog==6.7.0 +pip>=21.0,<23.2 +ruff==0.0.265 +pre-commit + +# Install HA and test dependencies (pytest, coverage) +# To pin the dev container to a specific HA version, set this dependency +# to the adequate version (add `==`) and rebuild the dev container. +# See https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases for version mappings. +pytest-homeassistant-custom-component diff --git a/scripts/develop b/scripts/develop new file mode 100644 index 00000000..e7ce50cd --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/adaptive_lighting +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/lint b/scripts/lint new file mode 100644 index 00000000..55a1f485 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +pre-commit run --all-files diff --git a/scripts/setup b/scripts/setup new file mode 100644 index 00000000..0688d70d --- /dev/null +++ b/scripts/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt +pre-commit install-hooks diff --git a/setup.cfg b/setup.cfg index 284326f5..a82d84b8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,3 +9,7 @@ max-complexity = 18 select = B,C,E,F,W,T4,B9 per-file-ignores = code_example.py: E402, E501 + +[tool:pytest] +testpaths = tests +asyncio_mode = auto diff --git a/tests/README.md b/tests/README.md index 1c472541..dd125d97 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,7 +1,7 @@ # Developer notes for the tests directory To run the tests, check out the [CI configuration](../.github/workflows/pytest.yml) to see how they are executed in the CI pipeline. -Alternatively, you can use the provided Docker image to run the tests locally. +Alternatively, you can use the provided Docker image to run the tests locally or run them with VS Code directly in the dev container. To run the tests using the Docker image, navigate to the `adaptive-lighting` repo folder and execute the following command: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..14f7e644 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +"""Fixtures for testing.""" +import os +import sys + +import pytest + +# Tests in the dev enviromentment use the pytest_homeassistant_custom_component instead of +# a cloned HA core repo for a simple and clean structure. To still test against a HA core +# clone (e.g. the dev branch for which no pytest_homeassistant_custom_component exists +# because HA does not publish dev snapshot packages), set the HA_CLONE env variable. +if "HA_CLONE" in os.environ: + # Rewire the testing package to the cloned test modules. See the test `Dockerfile` + # for setup details. + sys.modules["pytest_homeassistant_custom_component"] = __import__("tests") + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + yield diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 53901cf9..22384143 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,6 +1,10 @@ """Test Adaptive Lighting config flow.""" from homeassistant import data_entry_flow -from homeassistant.components.adaptive_lighting.const import ( +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_NAME +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.adaptive_lighting.const import ( CONF_SUNRISE_TIME, CONF_SUNSET_TIME, DEFAULT_NAME, @@ -8,10 +12,6 @@ NONE_STR, VALIDATION_TUPLES, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_NAME - -from tests.common import MockConfigEntry DEFAULT_DATA = {key: default for key, default, _ in VALIDATION_TUPLES} diff --git a/tests/test_init.py b/tests/test_init.py index b6f48e0b..bb0cf977 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,14 +1,11 @@ """Tests for Adaptive Lighting integration.""" -from homeassistant.components import adaptive_lighting -from homeassistant.components.adaptive_lighting.const import ( - DEFAULT_NAME, - UNDO_UPDATE_LISTENER, -) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_NAME from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import MockConfigEntry -from tests.common import MockConfigEntry +from custom_components import adaptive_lighting +from custom_components.adaptive_lighting.const import DEFAULT_NAME, UNDO_UPDATE_LISTENER async def test_setup_with_config(hass): diff --git a/tests/test_switch.py b/tests/test_switch.py index 0b0826dc..0e46f458 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -8,7 +8,49 @@ from random import randint from unittest.mock import MagicMock, patch -from homeassistant.components.adaptive_lighting.const import ( +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_TEMP_KELVIN, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + ATTR_TRANSITION, + ATTR_XY_COLOR, + COLOR_MODE_BRIGHTNESS, +) +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import SERVICE_TURN_OFF +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +import homeassistant.config as config_util +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_AREA_ID, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_LIGHTS, + CONF_NAME, + EVENT_STATE_CHANGED, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.setup import async_setup_component +from homeassistant.util.color import color_temperature_mired_to_kelvin +import homeassistant.util.dt as dt_util +import pytest +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + mock_area_registry, +) +import ulid_transform +import voluptuous.error + +from custom_components.adaptive_lighting.const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, ATTR_TURN_ON_OFF_LISTENER, @@ -38,7 +80,7 @@ SLEEP_MODE_SWITCH, UNDO_UPDATE_LISTENER, ) -from homeassistant.components.adaptive_lighting.switch import ( +from custom_components.adaptive_lighting.switch import ( _SUPPORT_OPTS, VALID_COLOR_MODES, _attributes_have_changed, @@ -48,47 +90,6 @@ create_context, is_our_context, ) -from homeassistant.components.demo.light import DemoLight -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_TEMP_KELVIN, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_RGB_COLOR, - ATTR_SUPPORTED_COLOR_MODES, - ATTR_TRANSITION, - ATTR_XY_COLOR, - COLOR_MODE_BRIGHTNESS, -) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.light import SERVICE_TURN_OFF -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -import homeassistant.config as config_util -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_AREA_ID, - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - CONF_LIGHTS, - CONF_NAME, - CONF_PLATFORM, - EVENT_STATE_CHANGED, - SERVICE_TURN_ON, - STATE_OFF, - STATE_ON, -) -from homeassistant.core import Context, State -from homeassistant.helpers import entity_registry -from homeassistant.setup import async_setup_component -from homeassistant.util.color import color_temperature_mired_to_kelvin -import homeassistant.util.dt as dt_util -import pytest -import ulid_transform -import voluptuous.error - -from tests.common import MockConfigEntry, mock_area_registry -from tests.components.demo.test_light import ENTITY_LIGHT _LOGGER = logging.getLogger(__name__) @@ -113,6 +114,7 @@ (32.87336, -117.22743, "US/Pacific"), ] +ENTITY_LIGHT = "light.bed_light" _SWITCH_FMT = f"{SWITCH_DOMAIN}.{DOMAIN}" ENTITY_SWITCH = f"{_SWITCH_FMT}_{DEFAULT_NAME}" ENTITY_SLEEP_MODE_SWITCH = f"{_SWITCH_FMT}_sleep_mode_{DEFAULT_NAME}" @@ -149,50 +151,60 @@ async def setup_switch(hass, extra_data): return entry, switch -async def setup_lights(hass): - """Set up 3 light entities using the 'test' platform.""" +async def setup_lights(hass: HomeAssistant): + """Set up 3 light entities using the 'template' platform.""" await async_setup_component( - hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": "demo"}} + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + { + "platform": "template", + "lights": { + "bed_light": { + "friendly_name": "Bed Light", + "unique_id": "light_1", + "turn_on": None, + "turn_off": None, + "set_level": None, + "set_temperature": None, + "set_color": None, + }, + "ceiling_lights": { + "friendly_name": "Ceiling Lights", + "unique_id": "light_2", + "turn_on": None, + "turn_off": None, + "set_level": None, + "set_temperature": None, + "set_color": None, + }, + "kitchen_lights": { + "friendly_name": "Kitchen Lights", + "unique_id": "light_3", + "turn_on": None, + "turn_off": None, + "set_level": None, + "set_temperature": None, + "set_color": None, + }, + }, + }, + ] + }, ) + await hass.async_block_till_done() + platform = async_get_platforms(hass, "template") + lights = list(platform[0].entities.values()) + + await lights[0].async_turn_on() + await lights[1].async_turn_on() - platform = getattr(hass.components, "test.light") - while platform.ENTITIES: - # Make sure it is empty - platform.ENTITIES.pop() - lights = [ - DemoLight( - unique_id="light_1", - name="Bed Light", - state=True, - ct=200, - ), - DemoLight( - unique_id="light_2", - name="Ceiling Lights", - state=True, - ct=380, - ), - DemoLight( - unique_id="light_3", - name="Kitchen Lights", - state=False, - hs_color=(345, 75), - ct=240, - ), - ] for light in lights: - light.hass = hass - slug = light.name.lower().replace(" ", "_") - light.entity_id = f"light.{slug}" - await light.async_update_ha_state() - - platform.ENTITIES.extend(lights) - platform.init() - assert await async_setup_component( - hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {CONF_PLATFORM: "test"}} - ) - await hass.async_block_till_done() + light._attr_brightness = 255 + light._attr_color_temp = 250 + assert all(hass.states.get(light.entity_id) is not None for light in lights) return lights @@ -646,11 +658,12 @@ async def change_manual_control(set_to, extra_service_data=None): await update() def increased_brightness(): - return (light._brightness + 100) % 255 + return (light._attr_brightness + 100) % 255 def increased_color_temp(): return max( - (light._ct + 100) % light.max_color_temp_kelvin, light.min_color_temp_kelvin + (light._attr_color_temp + 100) % light.max_color_temp_kelvin, + light.min_color_temp_kelvin, ) # Nothing is manually controlled @@ -703,7 +716,9 @@ def increased_color_temp(): color_temperature_mired_to_kelvin(mired_range[0]), ) ptp_kelvin = kelvin_range[1] - kelvin_range[0] - await turn_light(True, color_temp_kelvin=(light._ct + 100) % ptp_kelvin) + await turn_light( + True, color_temp_kelvin=(light._attr_color_temp + 100) % ptp_kelvin + ) assert manual_control[ENTITY_LIGHT] await switch.adapt_brightness_switch.async_turn_on() # turn on again @@ -805,11 +820,12 @@ async def test_apply_service(hass): assert entity_id not in switch._lights def increased_brightness(): - return (light._brightness + 100) % 255 + return (light._attr_brightness + 100) % 255 def increased_color_temp(): return max( - (light._ct + 100) % light.max_color_temp_kelvin, light.min_color_temp_kelvin + (light._attr_color_temp + 100) % light.max_color_temp_kelvin, + light.min_color_temp_kelvin, ) async def change_light(): @@ -1306,7 +1322,7 @@ async def test_area(hass): area_registry.async_create("test_area") entity = entity_registry.async_get(hass).async_get_or_create( - LIGHT_DOMAIN, "demo", light.unique_id + LIGHT_DOMAIN, "template", light.unique_id ) entity = entity_registry.async_get(hass).async_update_entity( entity.entity_id, area_id="test_area"