diff --git a/.devcontainer.json b/.devcontainer.json index c70f1f5..9a91ab6 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,6 +1,6 @@ { "name": "ludeeus/integration_blueprint", - "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", + "image": "mcr.microsoft.com/devcontainers/python:3.12", "postCreateCommand": "scripts/setup", "forwardPorts": [ 8123 @@ -14,29 +14,28 @@ "customizations": { "vscode": { "extensions": [ - "ms-python.python", + "charliermarsh.ruff", "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" + "ms-python.python", + "ms-python.vscode-pylance", + "ryanluker.vscode-coverage-gutters" ], "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.formatOnPaste": true, "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true + "editor.formatOnType": false, + "files.trimTrailingWhitespace": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + "python.defaultInterpreterPath": "/usr/local/bin/python", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } } } }, "remoteUser": "vscode", - "features": { - "ghcr.io/devcontainers/features/rust:1": {} - } -} + "features": {} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 566cd66..29cf09b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ venv .HA_VERSION config/.storage/* config/blueprints/* +.ruff_cache \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml index 860b56c..0a1d8e9 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,42 +1,115 @@ # 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 = [ +[lint] +select = [ + "A001", # Variable {name} is shadowing a Python builtin + "B002", # Python does not support the unary prefix increment + "B005", # Using .strip() with multi-character strings is misleading "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 + "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. + "B017", # pytest.raises(BaseException) should be considered evil + "B018", # Found useless attribute access. Either assign it to a variable or remove it. + "B023", # Function definition does not bind loop variable {name} + "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? + "B904", # Use raise from to specify exception cause + "B905", # zip() without an explicit strict= parameter + "BLE", + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "FLY", # flynt + "G", # flake8-logging-format + "I", # isort + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat "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 + "LOG", # flake8-logging + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N815", # Variable {name} in class scope should not be mixedCase + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PYI", # flake8-pyi + "RET", # flake8-return + "RSE", # flake8-raise + "RUF005", # Consider iterable unpacking instead of concatenation "RUF006", # Store a reference to the return value of asyncio.create_task - "UP", # pyupgrade - "W", # pycodestyle + "RUF010", # Use explicit conversion flag + "RUF013", # PEP 484 prohibits implicit Optional + "RUF018", # Avoid assignment expressions in assert statements + "RUF019", # Unnecessary key check before dictionary access + # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S320", # suspicious-xmle-tree-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T100", # Trace found: {name} used + "T20", # flake8-print + "TID251", # Banned imports + "TRY", # tryceratops + "UP", # pyupgrade + "W", # pycodestyle ] -lint.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 - +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 + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long + + "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "PT004", # Fixture {fixture} does not return anything, add leading underscore + "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception + "PT018", # Assertion should be broken down into multiple parts + "RUF001", # String contains ambiguous unicode character. + "RUF002", # Docstring contains ambiguous unicode character. + "RUF003", # Comment contains ambiguous unicode character. + "RUF015", # Prefer next(...) over single element slice + "SIM102", # Use a single if statement instead of nested if statements + "SIM103", # Return the condition {condition} directly + "SIM108", # Use ternary operator {contents} instead of if-else-block + "SIM115", # Use context handler for opening files + "TRY003", # Avoid specifying long messages outside the exception class + "TRY400", # Use `logging.exception` instead of `logging.error` + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", "E111", @@ -44,17 +117,21 @@ lint.ignore = [ "E117", "D206", "D300", - "Q000", - "Q001", - "Q002", - "Q003", + "Q", "COM812", "COM819", "ISC001", - "ISC002", # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605", + + # temporarily disabled + "PT019", + "PYI024", # Use typing.NamedTuple instead of collections.namedtuple + "RET503", + "RET501", + "TRY002", + "TRY301" ] [lint.flake8-pytest-style] @@ -64,4 +141,4 @@ fixture-parentheses = false keep-runtime-typing = true [lint.mccabe] -max-complexity = 25 \ No newline at end of file +max-complexity = 25 diff --git a/config/configuration.yaml b/config/configuration.yaml index 539bb19..ca39a21 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -1,8 +1,12 @@ # https://www.home-assistant.io/integrations/default_config/ default_config: +# https://www.home-assistant.io/integrations/homeassistant/ +homeassistant: + debug: true + # https://www.home-assistant.io/integrations/logger/ logger: default: info logs: - custom_components.pirateweather: debug + custom_components.integration_blueprint: debug \ No newline at end of file diff --git a/custom_components/pirateweather/__init__.py b/custom_components/pirateweather/__init__.py index 1977d0b..1376aaf 100644 --- a/custom_components/pirateweather/__init__.py +++ b/custom_components/pirateweather/__init__.py @@ -3,35 +3,31 @@ from __future__ import annotations import logging -from typing import Any - - from datetime import timedelta +from typing import Any from homeassistant.config_entries import ConfigEntry - - from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, - CONF_NAME, CONF_MONITORED_CONDITIONS, + CONF_NAME, CONF_SCAN_INTERVAL, ) from homeassistant.core import HomeAssistant from .const import ( + CONF_UNITS, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, PLATFORMS, - UPDATE_LISTENER, - CONF_UNITS, - PW_PLATFORMS, PW_PLATFORM, + PW_PLATFORMS, PW_ROUND, + UPDATE_LISTENER, ) # from .weather_update_coordinator import WeatherUpdateCoordinator, DarkSkyData @@ -65,7 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # _LOGGER.warning(forecast_days) if isinstance(forecast_days, str): # If empty, set to none - if forecast_days == "" or forecast_days == "None": + if forecast_days in {"", "None"}: forecast_days = None else: if forecast_days[0] == "[": @@ -76,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if isinstance(forecast_hours, str): # If empty, set to none - if forecast_hours == "" or forecast_hours == "None": + if forecast_hours in {"", "None"}: forecast_hours = None else: if forecast_hours[0] == "[": @@ -137,7 +133,6 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - pw_entity_prevplatform = hass.data[DOMAIN][entry.entry_id][PW_PLATFORM] # If both diff --git a/custom_components/pirateweather/config_flow.py b/custom_components/pirateweather/config_flow.py index 2bc8c45..9eeb51e 100644 --- a/custom_components/pirateweather/config_flow.py +++ b/custom_components/pirateweather/config_flow.py @@ -1,38 +1,38 @@ """Config flow for Pirate Weather.""" -import voluptuous as vol import logging from datetime import timedelta import aiohttp - +import homeassistant.helpers.config_validation as cv +import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, - CONF_NAME, CONF_MONITORED_CONDITIONS, + CONF_NAME, CONF_SCAN_INTERVAL, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from httpx import HTTPError from .const import ( + ALL_CONDITIONS, CONF_LANGUAGE, + CONF_UNITS, CONFIG_FLOW_VERSION, DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, + DEFAULT_UNITS, DOMAIN, LANGUAGES, - CONF_UNITS, - DEFAULT_UNITS, - ALL_CONDITIONS, - PW_PLATFORMS, PW_PLATFORM, + PW_PLATFORMS, PW_PREVPLATFORM, PW_ROUND, ) @@ -128,16 +128,18 @@ async def async_step_user(self, user_input=None): "Invalid API Key, Ensure that you've subscribed to API at https://pirate-weather.apiable.io/" ) - except Exception: - _LOGGER.warning("Pirate Weather Setup Error: HTTP Error: " + api_status) - errors["base"] = "API Error: " + api_status + except HTTPError: + _LOGGER.warning( + "Pirate Weather Setup Error: API HTTP Error: %s", api_status + ) + errors["base"] = "API Error: %s", api_status - if not errors: + if errors: + _LOGGER.warning(errors) + else: return self.async_create_entry( title=user_input[CONF_NAME], data=user_input ) - else: - _LOGGER.warning(errors) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) @@ -296,6 +298,4 @@ async def _is_pw_api_online(hass, api_key, lat, lon): aiohttp.ClientSession(raise_for_status=False) as session, session.get(forecastString) as resp, ): - status = resp.status - - return status + return resp.status diff --git a/custom_components/pirateweather/const.py b/custom_components/pirateweather/const.py index 9f6e662..75046a0 100644 --- a/custom_components/pirateweather/const.py +++ b/custom_components/pirateweather/const.py @@ -18,13 +18,13 @@ ) from homeassistant.const import ( DEGREE, - UnitOfLength, PERCENTAGE, + UV_INDEX, + Platform, + UnitOfLength, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, - UV_INDEX, - Platform, ) DOMAIN = "pirateweather" diff --git a/custom_components/pirateweather/sensor.py b/custom_components/pirateweather/sensor.py index c9525b2..9ea9628 100644 --- a/custom_components/pirateweather/sensor.py +++ b/custom_components/pirateweather/sensor.py @@ -1,14 +1,12 @@ """Support for Pirate Weather (Dark Sky Compatable) weather service.""" import logging - from dataclasses import dataclass, field +from typing import Literal, NamedTuple -import voluptuous as vol import homeassistant.helpers.config_validation as cv import homeassistant.helpers.template as template_helper - - +import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorDeviceClass, @@ -16,14 +14,7 @@ SensorEntityDescription, SensorStateClass, ) -from typing import Literal, NamedTuple - from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType -from homeassistant.helpers.typing import DiscoveryInfoType - from homeassistant.const import ( ATTR_ATTRIBUTION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -35,27 +26,28 @@ CONF_SCAN_INTERVAL, DEGREE, PERCENTAGE, + UV_INDEX, + UnitOfLength, + UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, - UnitOfLength, UnitOfVolumetricFlux, - UnitOfPrecipitationDepth, - UV_INDEX, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType, StateType from .const import ( + ALL_CONDITIONS, DEFAULT_SCAN_INTERVAL, DOMAIN, ENTRY_WEATHER_COORDINATOR, - ALL_CONDITIONS, - PW_PLATFORMS, PW_PLATFORM, + PW_PLATFORMS, PW_PREVPLATFORM, PW_ROUND, ) - - from .weather_update_coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -786,7 +778,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Pirate Weather sensor entities based on a config entry.""" - domain_data = hass.data[DOMAIN][config_entry.entry_id] name = domain_data[CONF_NAME] @@ -891,7 +882,6 @@ def __init__( """Initialize the sensor.""" self.client_name = name - description = description self.entity_description = description self.description = description @@ -985,15 +975,13 @@ def extra_state_attributes(self): if self.type == "alerts": extraATTR = self._alerts extraATTR[ATTR_ATTRIBUTION] = ATTRIBUTION - - return extraATTR else: - return {ATTR_ATTRIBUTION: ATTRIBUTION} + extraATTR = {ATTR_ATTRIBUTION: ATTRIBUTION} + return extraATTR @property def native_value(self) -> StateType: """Return the state of the device.""" - self.update_unit_of_measurement() if self.type == "alerts": diff --git a/custom_components/pirateweather/weather.py b/custom_components/pirateweather/weather.py index 10d8e4a..d0a35f0 100644 --- a/custom_components/pirateweather/weather.py +++ b/custom_components/pirateweather/weather.py @@ -4,20 +4,12 @@ import logging -import voluptuous as vol - import homeassistant.helpers.config_validation as cv -from homeassistant.util.dt import utc_from_timestamp -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.core import HomeAssistant, callback -from .weather_update_coordinator import WeatherUpdateCoordinator -from homeassistant.helpers.typing import DiscoveryInfoType - - +import voluptuous as vol from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_EXCEPTIONAL, ATTR_CONDITION_FOG, ATTR_CONDITION_HAIL, ATTR_CONDITION_LIGHTNING, @@ -27,14 +19,12 @@ ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, - ATTR_CONDITION_EXCEPTIONAL, PLATFORM_SCHEMA, Forecast, - WeatherEntityFeature, SingleCoordinatorWeatherEntity, + WeatherEntityFeature, ) - - +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -42,25 +32,30 @@ CONF_MODE, CONF_NAME, CONF_SCAN_INTERVAL, + UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, - UnitOfLength, ) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.util.dt import utc_from_timestamp from .const import ( + CONF_UNITS, DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, DOMAIN, - FORECAST_MODES, - CONF_UNITS, ENTRY_WEATHER_COORDINATOR, - PW_PLATFORMS, + FORECAST_MODES, PW_PLATFORM, + PW_PLATFORMS, PW_PREVPLATFORM, PW_ROUND, ) +from .weather_update_coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -326,7 +321,6 @@ def native_visibility(self): @property def condition(self): """Return the weather condition.""" - return MAP_CONDITION.get( self._weather_coordinator.data.currently().d.get("icon") ) diff --git a/custom_components/pirateweather/weather_update_coordinator.py b/custom_components/pirateweather/weather_update_coordinator.py index 4e02eb2..20dd3a2 100644 --- a/custom_components/pirateweather/weather_update_coordinator.py +++ b/custom_components/pirateweather/weather_update_coordinator.py @@ -1,16 +1,14 @@ """Weather data coordinator for the Pirate Weather service.""" +import json import logging +from http.client import HTTPException +import aiohttp import async_timeout from forecastio.models import Forecast -import json -import aiohttp - - from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - from .const import ( DOMAIN, ) @@ -45,19 +43,13 @@ async def _async_update_data(self): async with async_timeout.timeout(60): try: data = await self._get_pw_weather() - _LOGGER.debug( - "Pirate Weather data update for " - + str(self.latitude) - + "," - + str(self.longitude) - ) - except Exception as err: - raise UpdateFailed(f"Error communicating with API: {err}") + _LOGGER.debug("Pirate Weather data update") + except HTTPException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err return data async def _get_pw_weather(self): """Poll weather data from PW.""" - forecastString = ( "https://api.pirateweather.net/forecast/" + self._api_key @@ -80,5 +72,4 @@ async def _get_pw_weather(self): headers = resp.headers status = resp.raise_for_status() - data = Forecast(jsonText, status, headers) - return data + return Forecast(jsonText, status, headers) diff --git a/scripts/lint b/scripts/lint old mode 100644 new mode 100755