VS Code Dev Container (dev & test environment) (#605)

* build: dev container

Add a VS Code Dev Container from the blueprint at bceaae212f

* 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 <basnijholt@gmail.com>
This commit is contained in:
Mario Guggenberger 2023-06-12 00:33:07 +02:00 committed by GitHub
commit 6283158ff7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 329 additions and 111 deletions

42
.devcontainer.json Normal file
View file

@ -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": {}
}
}

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

View file

@ -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

8
.gitignore vendored
View file

@ -127,3 +127,11 @@ dmypy.json
# Pyre type checker
.pyre/
# IDEs
.vscode
.idea
# Home Assistant configuration
config/*
!config/configuration.yaml

48
.ruff.toml Normal file
View file

@ -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

17
.vscode/tasks.json vendored Normal file
View file

@ -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": []
}
]
}

View file

@ -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

19
config/configuration.yaml Normal file
View file

@ -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 }}"

10
requirements.txt Normal file
View file

@ -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 `==<version>`) and rebuild the dev container.
# See https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases for version mappings.
pytest-homeassistant-custom-component

20
scripts/develop Normal file
View file

@ -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 <root>/custom_components/adaptive_lighting
## while at the same time have Home Assistant configuration inside <root>/config
## without resulting to symlinks.
export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components"
# Start Home Assistant
hass --config "${PWD}/config" --debug

7
scripts/lint Normal file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
pre-commit run --all-files

8
scripts/setup Normal file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
python3 -m pip install --requirement requirements.txt
pre-commit install-hooks

View file

@ -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

View file

@ -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:

19
tests/conftest.py Normal file
View file

@ -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

View file

@ -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 @@ from homeassistant.components.adaptive_lighting.const import (
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}

View file

@ -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):

View file

@ -8,7 +8,49 @@ import logging
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 @@ from homeassistant.components.adaptive_lighting.const import (
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 @@ from homeassistant.components.adaptive_lighting.switch import (
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 @@ LAT_LONG_TZS = [
(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 = 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,
),
]
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()
for light in lights:
light.hass = hass
slug = light.name.lower().replace(" ", "_")
light.entity_id = f"light.{slug}"
await light.async_update_ha_state()
light._attr_brightness = 255
light._attr_color_temp = 250
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()
assert all(hass.states.get(light.entity_id) is not None for light in lights)
return lights
@ -646,11 +658,12 @@ async def test_manual_control(hass):
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 @@ async def test_manual_control(hass):
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"