diff --git a/.devcontainer.json b/.devcontainer.json index 9fda616..5cec644 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,6 +1,6 @@ { "name": "ludeeus/integration_blueprint", - "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.10-bullseye", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12", "postCreateCommand": "scripts/setup", "forwardPorts": [ 8123 @@ -17,17 +17,21 @@ "ms-python.python", "github.vscode-pull-request-github", "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" + "ms-python.vscode-pylance", + "ms-python.black-formatter", + "ms-python.pylint" ], "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", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "black-formatter.path": [ + "/usr/local/py-utils/bin/black" + ], "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 88fc142..9308388 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,9 +17,9 @@ jobs: uses: "actions/checkout@v4" - name: "Set up Python" - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version-file: 'pyproject.toml' cache: "pip" - name: "Install requirements" diff --git a/.github/workflows/py-dead-code.yml b/.github/workflows/py-dead-code.yml index 1453ec6..5e3744b 100644 --- a/.github/workflows/py-dead-code.yml +++ b/.github/workflows/py-dead-code.yml @@ -15,12 +15,12 @@ jobs: echo "package=$(ls -F | grep \/$ | grep -v "scripts\|examples\|tests\|config" | sed -n "s/\///g;1p")" >> $GITHUB_ENV - name: "Set up Python" - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version-file: 'pyproject.toml' - name: "Cache pip" - uses: actions/cache@v3 + uses: actions/cache@v4 with: # This path is specific to Ubuntu path: ~/.cache/pip diff --git a/.github/workflows/py-test.yml b/.github/workflows/py-test.yml index f6c3288..d3fabf7 100644 --- a/.github/workflows/py-test.yml +++ b/.github/workflows/py-test.yml @@ -23,12 +23,12 @@ jobs: echo "package=$(ls -F | grep \/$ | grep -v "bin\|examples\|tests" | sed -n "s/\///g;1p")" >> $GITHUB_ENV - name: "Set up Python" - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version-file: 'pyproject.toml' - name: "Cache pip" - uses: actions/cache@v3 + uses: actions/cache@v4 with: # This path is specific to Ubuntu path: ~/.cache/pip @@ -63,21 +63,17 @@ jobs: name: "Test package" needs: lint runs-on: ubuntu-latest - strategy: - max-parallel: 3 - matrix: - python-version: ['3.9', '3.10', '3.11'] steps: - name: "Checkout code" uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version-file: 'pyproject.toml' - name: "Cache pip" - uses: actions/cache@v3 + uses: actions/cache@v4 with: # This path is specific to Ubuntu path: ~/.cache/pip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebdada0..6a18104 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,13 +54,13 @@ jobs: - name: "Set up Python" if: env.release_version != '' && success() - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version-file: 'pyproject.toml' - name: "Cache pip" if: env.release_version != '' && success() - uses: actions/cache@v3 + uses: actions/cache@v4 with: # This path is specific to Ubuntu path: ~/.cache/pip diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea74596..97b27ac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,20 +7,20 @@ repos: language: script files: ^(custom_components/.+/const\.py|requirements\.txt)$ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.241 + rev: v0.3.5 hooks: - id: ruff args: - --fix files: ^(custom_components|bin|tests)/.+\.py$ - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.15.2 hooks: - id: pyupgrade - args: [ --py310-plus ] + args: [ --py312-plus ] stages: [manual] - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 24.3.0 hooks: - id: black args: @@ -37,7 +37,7 @@ repos: files: ^(custom_components|bin|tests)/.+\.py$ stages: [manual] - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 + rev: 1.7.8 hooks: - id: bandit args: diff --git a/.ruff.toml b/.ruff.toml index 7a8331a..879a9d9 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,48 +1,49 @@ # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml -target-version = "py310" +target-version = "py312" +[lint] 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} + "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 + "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 + "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] +[lint.flake8-pytest-style] fixture-parentheses = false -[pyupgrade] +[lint.pyupgrade] keep-runtime-typing = true -[mccabe] -max-complexity = 25 \ No newline at end of file +[lint.mccabe] +max-complexity = 25 diff --git a/custom_components/snowtire/binary_sensor.py b/custom_components/snowtire/binary_sensor.py index 125799f..9a805c4 100644 --- a/custom_components/snowtire/binary_sensor.py +++ b/custom_components/snowtire/binary_sensor.py @@ -11,20 +11,21 @@ from collections.abc import Callable from datetime import datetime import logging -from typing import Optional import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.components.weather import ( - ATTR_FORECAST, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_WEATHER_TEMPERATURE, DOMAIN as WEATHER, + SERVICE_GET_FORECASTS, + WeatherEntityFeature, ) from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_START, @@ -89,7 +90,7 @@ class SnowtireBinarySensor(BinarySensorEntity): def __init__( self, - unique_id: Optional[str], + unique_id: str | None, friendly_name: str, weather_entity: str, days: int, @@ -140,8 +141,8 @@ def icon(self): @staticmethod def _temp2c( - temperature: Optional[float], temperature_unit: Optional[str] - ) -> Optional[float]: + temperature: float | None, temperature_unit: str | None + ) -> float | None: """Convert weather temperature to Celsius degree.""" if temperature is not None and temperature_unit != TEMP_CELSIUS: temperature = TemperatureConverter.convert( @@ -161,9 +162,21 @@ async def async_update( f"Unable to find an entity called {self._weather_entity}" ) + if ( + wdata.attributes.get(ATTR_SUPPORTED_FEATURES) + is not WeatherEntityFeature.FORECAST_DAILY + ): + raise HomeAssistantError("Weather entity doesn't support 'daily' forecast") + tmpu = self.hass.config.units.temperature_unit temp = wdata.attributes.get(ATTR_WEATHER_TEMPERATURE) - forecast = wdata.attributes.get(ATTR_FORECAST) + forecast = await self.hass.services.async_call( + WEATHER, + SERVICE_GET_FORECASTS, + {"type": "daily", "entity_id": [self._weather_entity]}, + blocking=True, + return_response=True, + ) if forecast is None: raise HomeAssistantError( @@ -180,7 +193,7 @@ async def async_update( _LOGGER.debug("Inspect weather forecast from %s till %s", cur_date, stop_date) temp = [self._temp2c(temp, tmpu)] - for fcast in forecast: + for fcast in forecast[self._weather_entity]["forecast"]: fc_date = fcast.get(ATTR_FORECAST_TIME) if isinstance(fc_date, int): fc_date = dt_util.as_local( diff --git a/custom_components/snowtire/manifest.json b/custom_components/snowtire/manifest.json index 4e63664..1a91a55 100644 --- a/custom_components/snowtire/manifest.json +++ b/custom_components/snowtire/manifest.json @@ -11,9 +11,6 @@ "documentation": "https://github.com/Limych/ha-snowtire", "iot_class": "calculated", "issue_tracker": "https://github.com/Limych/ha-snowtire/issues", - "requirements": [ - "colorlog==6.7.0", - "ruff==0.1.1" - ], + "requirements": [], "version": "1.4.7-alpha" -} \ No newline at end of file +} diff --git a/hacs.json b/hacs.json index d0f8f0a..3416dad 100644 --- a/hacs.json +++ b/hacs.json @@ -2,7 +2,7 @@ "name": "Snowtire Sensor", "filename": "snowtire.zip", "hide_default_branch": true, - "homeassistant": "2023.1.0", + "homeassistant": "2024.4", "render_readme": true, "zip_release": true } diff --git a/pylintrc b/pylintrc index 850e50d..7b6263c 100644 --- a/pylintrc +++ b/pylintrc @@ -3,7 +3,7 @@ ignore=tests # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs=2 -load-plugins=pylint_strict_informational +fail-on=I persistent=no extension-pkg-whitelist=ciso8601 diff --git a/pyproject.toml b/pyproject.toml index cb14c1b..4229e51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,8 @@ +[project] +requires-python = ">=3.12" + [tool.black] -target-version = ["py310"] +target-version = ["py312"] extend-exclude = "/generated/" [tool.isort] @@ -7,20 +10,13 @@ extend-exclude = "/generated/" profile = "black" # will group `import x` and `from x import` of the same module. force_sort_within_sections = true -known_first_party = [ - "homeassistant", - "tests", -] -forced_separate = [ - "tests", -] +known_first_party = ["homeassistant", "tests"] +forced_separate = ["tests"] combine_as_imports = true [tool.pylint.MAIN] py-version = "3.10" -ignore = [ - "tests", -] +ignore = ["tests"] # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs = 2 @@ -50,24 +46,11 @@ extension-pkg-allow-list = [ "orjson", "cv2", ] -fail-on = [ - "I", -] +fail-on = ["I"] [tool.pylint.BASIC] class-const-naming-style = "any" -good-names = [ - "_", - "ev", - "ex", - "fp", - "i", - "id", - "j", - "k", - "Run", - "ip", -] +good-names = ["_", "ev", "ex", "fp", "i", "id", "j", "k", "Run", "ip"] [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: @@ -123,7 +106,7 @@ score = false [tool.pylint.TYPECHECK] ignored-classes = [ - "_CountingAttr", # for attrs + "_CountingAttr", # for attrs ] mixin-class-rgx = ".*[Mm]ix[Ii]n" @@ -147,50 +130,42 @@ max-line-length-suggestions = 72 # hass-component-root-import: Tests test non-public APIs # protected-access: Tests do often test internals a lot # redefined-outer-name: Tests reference fixtures in the test function -"/tests/"="hass-component-root-import,protected-access,redefined-outer-name" +"/tests/" = "hass-component-root-import,protected-access,redefined-outer-name" [tool.pytest.ini_options] -testpaths = [ - "tests", -] -norecursedirs = [ - ".git", - "testing_config", -] +testpaths = ["tests"] +norecursedirs = [".git", "testing_config"] log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" -[tool.ruff] -target-version = "py310" - select = [ - "C", # complexity - "D", # docstrings - "E", # pycodestyle - "F", # pyflakes/autoflake + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake "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 - "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 - "UP", # pyupgrade - "W", # pycodestyle + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "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 + "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 + "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 ] [tool.ruff.flake8-pytest-style] diff --git a/requirements-dev.txt b/requirements-dev.txt index 1e05453..551c0b0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements-test.txt -black==23.9.1 +black>=24.3.0 packaging==23.2 pre-commit~=3.5 PyGithub~=2.1 diff --git a/requirements-test.txt b/requirements-test.txt index 08cb07a..e800335 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -3,9 +3,9 @@ asynctest~=0.13 flake8~=6.1 flake8-docstrings~=1.7 mypy==1.5.1 -pylint~=3.0 +pylint~=3.1.0 pylint-strict-informational==0.1 pytest>=7.2 pytest-cov>=3.0 -pytest-homeassistant-custom-component>=0.12 +pytest-homeassistant-custom-component>=0.13 tzdata diff --git a/requirements.txt b/requirements.txt index 580b053..2121e1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ colorlog==6.7.0 -homeassistant>=2023.1.0 -pip>=21.0,<23.3 -ruff==0.1.1 +homeassistant>=2024.4 +pip>=24.0 +ruff>=0.3.5 diff --git a/tests/bandit.yaml b/tests/bandit.yaml index ebd284e..dcacabd 100644 --- a/tests/bandit.yaml +++ b/tests/bandit.yaml @@ -12,6 +12,5 @@ tests: - B318 - B319 - B320 - - B325 - B602 - B604 diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index 8ba01a6..13e9603 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -1,10 +1,12 @@ """The test for the snowtire binary sensor platform.""" + # pylint: disable=redefined-outer-name from typing import Final from unittest.mock import MagicMock import pytest from pytest import raises +from pytest_homeassistant_custom_component.common import async_mock_service from custom_components.snowtire.binary_sensor import ( SnowtireBinarySensor, @@ -17,13 +19,18 @@ ICON_WINTER, ) from homeassistant.components.weather import ( - ATTR_FORECAST, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_WEATHER_TEMPERATURE, + SERVICE_GET_FORECASTS, + WeatherEntityFeature, +) +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + CONF_PLATFORM, + UnitOfTemperature, ) -from homeassistant.const import CONF_PLATFORM, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util @@ -88,9 +95,12 @@ async def test_async_added_to_hass(default_sensor): ) async def test__temp2c(temp1, temp2): """Test temperature conversions.""" - assert SnowtireBinarySensor._temp2c(temp1, TEMP_CELSIUS) == temp1 - assert round(SnowtireBinarySensor._temp2c(temp1, TEMP_FAHRENHEIT), 2) == temp2 - assert SnowtireBinarySensor._temp2c(None, TEMP_CELSIUS) is None + assert SnowtireBinarySensor._temp2c(temp1, UnitOfTemperature.CELSIUS) == temp1 + assert ( + round(SnowtireBinarySensor._temp2c(temp1, UnitOfTemperature.FAHRENHEIT), 2) + == temp2 + ) + assert SnowtireBinarySensor._temp2c(None, UnitOfTemperature.CELSIUS) is None async def test_async_update(hass: HomeAssistant, default_sensor): @@ -109,30 +119,36 @@ async def test_async_update(hass: HomeAssistant, default_sensor): today_ts = int(today.timestamp() * 1000) day = days = 86400000 - forecast = [ - { - ATTR_FORECAST_TIME: today_ts - day, - }, - { - ATTR_FORECAST_TIME: today, - ATTR_FORECAST_TEMP: 9, - }, - { - ATTR_FORECAST_TIME: today_ts + day, - ATTR_FORECAST_TEMP_LOW: 1, - ATTR_FORECAST_TEMP: 8, - }, - { - ATTR_FORECAST_TIME: today_ts + (MOCK_DAYS + 1) * days, - }, - ] + forecast = { + MOCK_WEATHER_ENTITY: { + "forecast": [ + { + ATTR_FORECAST_TIME: today_ts - day, + }, + { + ATTR_FORECAST_TIME: today, + ATTR_FORECAST_TEMP: 9, + }, + { + ATTR_FORECAST_TIME: today_ts + day, + ATTR_FORECAST_TEMP_LOW: 1, + ATTR_FORECAST_TEMP: 8, + }, + { + ATTR_FORECAST_TIME: today_ts + (MOCK_DAYS + 1) * days, + }, + ] + } + } + + async_mock_service(hass, CONF_WEATHER, SERVICE_GET_FORECASTS, response=forecast) hass.states.async_set( MOCK_WEATHER_ENTITY, "State", attributes={ ATTR_WEATHER_TEMPERATURE: -1, - ATTR_FORECAST: forecast, + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_DAILY, }, ) @@ -146,7 +162,7 @@ async def test_async_update(hass: HomeAssistant, default_sensor): "State", attributes={ ATTR_WEATHER_TEMPERATURE: 9.9, - ATTR_FORECAST: forecast, + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_DAILY, }, ) @@ -160,7 +176,7 @@ async def test_async_update(hass: HomeAssistant, default_sensor): "State", attributes={ ATTR_WEATHER_TEMPERATURE: 10, - ATTR_FORECAST: forecast, + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_DAILY, }, )