[config_validation] Add support for suggesting alternate component/platform (#9757)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
Keith Burzinski 2025-07-21 18:29:05 -05:00 committed by Jesse Hills
parent 76e75f4cdc
commit f8777d3b66
No known key found for this signature in database
GPG Key ID: BEAAE804EFD8E83A

View File

@ -73,6 +73,7 @@ from esphome.const import (
TYPE_GIT, TYPE_GIT,
TYPE_LOCAL, TYPE_LOCAL,
VALID_SUBSTITUTIONS_CHARACTERS, VALID_SUBSTITUTIONS_CHARACTERS,
Framework,
__version__ as ESPHOME_VERSION, __version__ as ESPHOME_VERSION,
) )
from esphome.core import ( from esphome.core import (
@ -282,6 +283,38 @@ class FinalExternalInvalid(Invalid):
"""Represents an invalid value in the final validation phase where the path should not be prepended.""" """Represents an invalid value in the final validation phase where the path should not be prepended."""
@dataclass(frozen=True, order=True)
class Version:
major: int
minor: int
patch: int
extra: str = ""
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
@classmethod
def parse(cls, value: str) -> Version:
match = re.match(r"^(\d+).(\d+).(\d+)-?(\w*)$", value)
if match is None:
raise ValueError(f"Not a valid version number {value}")
major = int(match[1])
minor = int(match[2])
patch = int(match[3])
extra = match[4] or ""
return Version(major=major, minor=minor, patch=patch, extra=extra)
@property
def is_beta(self) -> bool:
"""Check if this version is a beta version."""
return self.extra.startswith("b")
@property
def is_dev(self) -> bool:
"""Check if this version is a development version."""
return self.extra.startswith("dev")
def check_not_templatable(value): def check_not_templatable(value):
if isinstance(value, Lambda): if isinstance(value, Lambda):
raise Invalid("This option is not templatable!") raise Invalid("This option is not templatable!")
@ -619,16 +652,35 @@ def only_on(platforms):
return validator_ return validator_
def only_with_framework(frameworks): def only_with_framework(
frameworks: Framework | str | list[Framework | str], suggestions=None
):
"""Validate that this option can only be specified on the given frameworks.""" """Validate that this option can only be specified on the given frameworks."""
if not isinstance(frameworks, list): if not isinstance(frameworks, list):
frameworks = [frameworks] frameworks = [frameworks]
frameworks = [Framework(framework) for framework in frameworks]
if suggestions is None:
suggestions = {}
version = Version.parse(ESPHOME_VERSION)
if version.is_beta:
docs_format = "https://beta.esphome.io/components/{path}"
elif version.is_dev:
docs_format = "https://next.esphome.io/components/{path}"
else:
docs_format = "https://esphome.io/components/{path}"
def validator_(obj): def validator_(obj):
if CORE.target_framework not in frameworks: if CORE.target_framework not in frameworks:
raise Invalid( err_str = f"This feature is only available with framework(s) {', '.join([framework.value for framework in frameworks])}"
f"This feature is only available with frameworks {frameworks}" if suggestion := suggestions.get(CORE.target_framework, None):
) (component, docs_path) = suggestion
err_str += f"\nPlease use '{component}'"
if docs_path:
err_str += f": {docs_format.format(path=docs_path)}"
raise Invalid(err_str)
return obj return obj
return validator_ return validator_
@ -637,8 +689,8 @@ def only_with_framework(frameworks):
only_on_esp32 = only_on(PLATFORM_ESP32) only_on_esp32 = only_on(PLATFORM_ESP32)
only_on_esp8266 = only_on(PLATFORM_ESP8266) only_on_esp8266 = only_on(PLATFORM_ESP8266)
only_on_rp2040 = only_on(PLATFORM_RP2040) only_on_rp2040 = only_on(PLATFORM_RP2040)
only_with_arduino = only_with_framework("arduino") only_with_arduino = only_with_framework(Framework.ARDUINO)
only_with_esp_idf = only_with_framework("esp-idf") only_with_esp_idf = only_with_framework(Framework.ESP_IDF)
# Adapted from: # Adapted from:
@ -1965,26 +2017,6 @@ def source_refresh(value: str):
return positive_time_period_seconds(value) return positive_time_period_seconds(value)
@dataclass(frozen=True, order=True)
class Version:
major: int
minor: int
patch: int
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
@classmethod
def parse(cls, value: str) -> Version:
match = re.match(r"^(\d+).(\d+).(\d+)-?\w*$", value)
if match is None:
raise ValueError(f"Not a valid version number {value}")
major = int(match[1])
minor = int(match[2])
patch = int(match[3])
return Version(major=major, minor=minor, patch=patch)
def version_number(value): def version_number(value):
value = string_strict(value) value = string_strict(value)
try: try: