[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 GitHub
parent 118b74b7cd
commit 238c72b66f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 59 additions and 27 deletions

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:
@ -1966,26 +2018,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:

View File

@ -266,7 +266,7 @@ def test_framework_specific_errors(
with pytest.raises( with pytest.raises(
cv.Invalid, cv.Invalid,
match=r"This feature is only available with frameworks \['esp-idf'\]", match=r"This feature is only available with framework\(s\) esp-idf",
): ):
run_schema_validation({"model": "wt32-sc01-plus"}) run_schema_validation({"model": "wt32-sc01-plus"})