Add deprecation to legacy forecast for Weather (#97294)

* Add deprecation to legacy forecast

* Mod _reported

* issue

* remove not need variable

* kitchen_sink

* 2024.3

* remove demo and mqtt

* add checks

* fix deprecation

* remove variable

* fix kitchen_sink

* Fix deprecation warning

* Expand issue

* clean

* Fix tests

* fix kitchen_sink

* not report on core integrations
This commit is contained in:
G Johansson 2023-08-29 10:38:59 +02:00 committed by GitHub
parent b22b51fe3b
commit 7a690d7359
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 405 additions and 0 deletions

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import abc import abc
import asyncio
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
@ -47,6 +48,8 @@ from homeassistant.helpers.config_validation import ( # noqa: F401
) )
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
import homeassistant.helpers.issue_registry as ir
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import (
CoordinatorEntity, CoordinatorEntity,
@ -318,6 +321,9 @@ class WeatherEntity(Entity, PostInit):
Literal["daily", "hourly", "twice_daily"], Literal["daily", "hourly", "twice_daily"],
list[Callable[[list[JsonValueType] | None], None]], list[Callable[[list[JsonValueType] | None], None]],
] ]
__weather_legacy_forecast: bool = False
__weather_legacy_forecast_reported: bool = False
__report_issue: str
_weather_option_temperature_unit: str | None = None _weather_option_temperature_unit: str | None = None
_weather_option_pressure_unit: str | None = None _weather_option_pressure_unit: str | None = None
@ -381,6 +387,59 @@ class WeatherEntity(Entity, PostInit):
cls.__name__, cls.__name__,
report_issue, report_issue,
) )
if any(
method in cls.__dict__ for method in ("_attr_forecast", "forecast")
) and not any(
method in cls.__dict__
for method in (
"async_forecast_daily",
"async_forecast_hourly",
"async_forecast_twice_daily",
)
):
cls.__weather_legacy_forecast = True
@callback
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
_reported_forecast = False
if self.__weather_legacy_forecast and not _reported_forecast:
module = inspect.getmodule(self)
if module and module.__file__ and "custom_components" in module.__file__:
# Do not report on core integrations as they are already fixed or PR is open.
report_issue = "report it to the custom integration author."
_LOGGER.warning(
(
"%s::%s is using a forecast attribute on an instance of "
"WeatherEntity, this is deprecated and will be unsupported "
"from Home Assistant 2024.3. Please %s"
),
self.__module__,
self.entity_id,
report_issue,
)
ir.async_create_issue(
self.hass,
DOMAIN,
f"deprecated_weather_forecast_{self.platform.platform_name}",
breaks_in_ha_version="2024.3.0",
is_fixable=False,
is_persistent=False,
issue_domain=self.platform.platform_name,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_weather_forecast",
translation_placeholders={
"platform": self.platform.platform_name,
"report_issue": report_issue,
},
)
_reported_forecast = True
async def async_internal_added_to_hass(self) -> None: async def async_internal_added_to_hass(self) -> None:
"""Call when the weather entity is added to hass.""" """Call when the weather entity is added to hass."""

View File

@ -98,5 +98,11 @@
} }
} }
} }
},
"issues": {
"deprecated_weather_forecast": {
"title": "The {platform} integration is using deprecated forecast",
"description": "The integration `{platform}` is using the deprecated forecast attribute.\n\nPlease {report_issue}."
}
} }
} }

View File

@ -58,6 +58,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.issue_registry as ir
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import ( from homeassistant.util.unit_conversion import (
@ -71,6 +72,9 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
from . import create_entity from . import create_entity
from tests.testing_config.custom_components.test import weather as WeatherPlatform from tests.testing_config.custom_components.test import weather as WeatherPlatform
from tests.testing_config.custom_components.test_weather import (
weather as NewWeatherPlatform,
)
from tests.typing import WebSocketGenerator from tests.typing import WebSocketGenerator
@ -1225,3 +1229,88 @@ async def test_get_forecast_unsupported(
blocking=True, blocking=True,
return_response=True, return_response=True,
) )
async def test_issue_forecast_deprecated(
hass: HomeAssistant,
enable_custom_integrations: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the issue is raised on deprecated forecast attributes."""
kwargs = {
"native_temperature": 38,
"native_temperature_unit": UnitOfTemperature.CELSIUS,
}
platform: WeatherPlatform = getattr(hass.components, "test.weather")
caplog.clear()
platform.init(empty=True)
platform.ENTITIES.append(
platform.MockWeatherMockLegacyForecastOnly(
name="Testing",
entity_id="weather.testing",
condition=ATTR_CONDITION_SUNNY,
**kwargs,
)
)
entity0 = platform.ENTITIES[0]
assert await async_setup_component(
hass, "weather", {"weather": {"platform": "test", "name": "testing"}}
)
await hass.async_block_till_done()
assert entity0.state == ATTR_CONDITION_SUNNY
issues = ir.async_get(hass)
issue = issues.async_get_issue("weather", "deprecated_weather_forecast_test")
assert issue
assert issue.issue_domain == "test"
assert issue.issue_id == "deprecated_weather_forecast_test"
assert issue.translation_placeholders == {
"platform": "test",
"report_issue": "report it to the custom integration author.",
}
assert (
"custom_components.test.weather::weather.testing is using a forecast attribute on an instance of WeatherEntity"
in caplog.text
)
async def test_issue_forecast_deprecated_no_logging(
hass: HomeAssistant,
enable_custom_integrations: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the no issue is raised on deprecated forecast attributes if new methods exist."""
kwargs = {
"native_temperature": 38,
"native_temperature_unit": UnitOfTemperature.CELSIUS,
}
platform: NewWeatherPlatform = getattr(hass.components, "test_weather.weather")
caplog.clear()
platform.init(empty=True)
platform.ENTITIES.append(
platform.MockWeatherMockForecast(
name="Test",
entity_id="weather.test",
condition=ATTR_CONDITION_SUNNY,
**kwargs,
)
)
entity0 = platform.ENTITIES[0]
assert await async_setup_component(
hass, "weather", {"weather": {"platform": "test_weather", "name": "test"}}
)
await hass.async_block_till_done()
assert entity0.state == ATTR_CONDITION_SUNNY
assert "Setting up weather.test_weather" in caplog.text
assert (
"custom_components.test_weather.weather::weather.test is using a forecast attribute on an instance of WeatherEntity"
not in caplog.text
)

View File

@ -298,6 +298,37 @@ class MockWeatherMockForecast(MockWeather):
] ]
class MockWeatherMockLegacyForecastOnly(MockWeather):
"""Mock weather class with mocked legacy forecast."""
def __init__(self, **values: Any) -> None:
"""Initialize."""
super().__init__(**values)
self.forecast_list: list[Forecast] | None = [
{
ATTR_FORECAST_NATIVE_TEMP: self.native_temperature,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature,
ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature,
ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point,
ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage,
ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed,
ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed,
ATTR_FORECAST_WIND_BEARING: self.wind_bearing,
ATTR_FORECAST_UV_INDEX: self.uv_index,
ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get(
"native_precipitation"
),
ATTR_FORECAST_HUMIDITY: self.humidity,
}
]
@property
def forecast(self) -> list[Forecast] | None:
"""Return the forecast."""
return self.forecast_list
class MockWeatherMockForecastCompat(MockWeatherCompat): class MockWeatherMockForecastCompat(MockWeatherCompat):
"""Mock weather class with mocked forecast for compatibility check.""" """Mock weather class with mocked forecast for compatibility check."""

View File

@ -0,0 +1 @@
"""An integration with Weather platform."""

View File

@ -0,0 +1,9 @@
{
"domain": "test_weather",
"name": "Test Weather",
"documentation": "http://example.com",
"requirements": [],
"dependencies": [],
"codeowners": [],
"version": "1.2.3"
}

View File

@ -0,0 +1,210 @@
"""Provide a mock weather platform.
Call init before using it in your tests to ensure clean test data.
"""
from __future__ import annotations
from typing import Any
from homeassistant.components.weather import (
ATTR_FORECAST_CLOUD_COVERAGE,
ATTR_FORECAST_HUMIDITY,
ATTR_FORECAST_IS_DAYTIME,
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
ATTR_FORECAST_NATIVE_DEW_POINT,
ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_FORECAST_NATIVE_PRESSURE,
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_UV_INDEX,
ATTR_FORECAST_WIND_BEARING,
Forecast,
WeatherEntity,
)
from tests.common import MockEntity
ENTITIES = []
def init(empty=False):
"""Initialize the platform with entities."""
global ENTITIES
ENTITIES = [] if empty else [MockWeatherMockForecast()]
async def async_setup_platform(
hass, config, async_add_entities_callback, discovery_info=None
):
"""Return mock entities."""
async_add_entities_callback(ENTITIES)
class MockWeatherMockForecast(MockEntity, WeatherEntity):
"""Mock weather class."""
def __init__(self, **values: Any) -> None:
"""Initialize."""
super().__init__(**values)
self.forecast_list: list[Forecast] | None = [
{
ATTR_FORECAST_NATIVE_TEMP: self.native_temperature,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature,
ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature,
ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point,
ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage,
ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed,
ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed,
ATTR_FORECAST_WIND_BEARING: self.wind_bearing,
ATTR_FORECAST_UV_INDEX: self.uv_index,
ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get(
"native_precipitation"
),
ATTR_FORECAST_HUMIDITY: self.humidity,
}
]
@property
def forecast(self) -> list[Forecast] | None:
"""Return the forecast."""
return self.forecast_list
async def async_forecast_daily(self) -> list[Forecast] | None:
"""Return the forecast_daily."""
return self.forecast_list
async def async_forecast_twice_daily(self) -> list[Forecast] | None:
"""Return the forecast_twice_daily."""
return [
{
ATTR_FORECAST_NATIVE_TEMP: self.native_temperature,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature,
ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature,
ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point,
ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage,
ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed,
ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed,
ATTR_FORECAST_WIND_BEARING: self.wind_bearing,
ATTR_FORECAST_UV_INDEX: self.uv_index,
ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get(
"native_precipitation"
),
ATTR_FORECAST_HUMIDITY: self.humidity,
ATTR_FORECAST_IS_DAYTIME: self._values.get("is_daytime"),
}
]
async def async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the forecast_hourly."""
return [
{
ATTR_FORECAST_NATIVE_TEMP: self.native_temperature,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature,
ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature,
ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point,
ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage,
ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed,
ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed,
ATTR_FORECAST_WIND_BEARING: self.wind_bearing,
ATTR_FORECAST_UV_INDEX: self.uv_index,
ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get(
"native_precipitation"
),
ATTR_FORECAST_HUMIDITY: self.humidity,
}
]
@property
def native_temperature(self) -> float | None:
"""Return the platform temperature."""
return self._handle("native_temperature")
@property
def native_apparent_temperature(self) -> float | None:
"""Return the platform apparent temperature."""
return self._handle("native_apparent_temperature")
@property
def native_dew_point(self) -> float | None:
"""Return the platform dewpoint temperature."""
return self._handle("native_dew_point")
@property
def native_temperature_unit(self) -> str | None:
"""Return the unit of measurement for temperature."""
return self._handle("native_temperature_unit")
@property
def native_pressure(self) -> float | None:
"""Return the pressure."""
return self._handle("native_pressure")
@property
def native_pressure_unit(self) -> str | None:
"""Return the unit of measurement for pressure."""
return self._handle("native_pressure_unit")
@property
def humidity(self) -> float | None:
"""Return the humidity."""
return self._handle("humidity")
@property
def native_wind_gust_speed(self) -> float | None:
"""Return the wind speed."""
return self._handle("native_wind_gust_speed")
@property
def native_wind_speed(self) -> float | None:
"""Return the wind speed."""
return self._handle("native_wind_speed")
@property
def native_wind_speed_unit(self) -> str | None:
"""Return the unit of measurement for wind speed."""
return self._handle("native_wind_speed_unit")
@property
def wind_bearing(self) -> float | str | None:
"""Return the wind bearing."""
return self._handle("wind_bearing")
@property
def ozone(self) -> float | None:
"""Return the ozone level."""
return self._handle("ozone")
@property
def cloud_coverage(self) -> float | None:
"""Return the cloud coverage in %."""
return self._handle("cloud_coverage")
@property
def uv_index(self) -> float | None:
"""Return the UV index."""
return self._handle("uv_index")
@property
def native_visibility(self) -> float | None:
"""Return the visibility."""
return self._handle("native_visibility")
@property
def native_visibility_unit(self) -> str | None:
"""Return the unit of measurement for visibility."""
return self._handle("native_visibility_unit")
@property
def native_precipitation_unit(self) -> str | None:
"""Return the native unit of measurement for accumulated precipitation."""
return self._handle("native_precipitation_unit")
@property
def condition(self) -> str | None:
"""Return the current condition."""
return self._handle("condition")