diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 2de6898177..c04d43bbee 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -99,7 +99,6 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) -from esphome.util import parse_esphome_version from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base @@ -2612,13 +2611,30 @@ def require_framework_version( return validator -def require_esphome_version(year, month, patch): +def require_esphome_version( + year: Version | int, month: int | None = None, patch: int | None = None +): + """Validator requiring at least the given ESPHome version. + + Accepts a single ``Version`` like the sibling + ``require_framework_version``, or the legacy ``(year, month, patch)`` + ints external components already pass. + """ + if isinstance(year, Version): + required = year + elif month is None or patch is None: + raise ValueError( + "require_esphome_version needs a Version or (year, month, patch)" + ) + else: + required = Version(year, month, patch) + def validator(value): - esphome_version = parse_esphome_version() - if esphome_version < (year, month, patch): - requires_version = f"{year}.{month}.{patch}" + # A dev or beta build of the required version still satisfies it, + # matching the old tuple comparison that dropped the suffix. + if Version.parse(ESPHOME_VERSION) < required: raise Invalid( - f"This component requires at least ESPHome version {requires_version}" + f"This component requires at least ESPHome version {required}" ) return value diff --git a/esphome/util.py b/esphome/util.py index ed95ea24d2..4a5986a90d 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -329,13 +329,6 @@ def is_dev_esphome_version(): return "dev" in const.__version__ -def parse_esphome_version() -> tuple[int, int, int]: - match = re.match(r"^(\d+).(\d+).(\d+)(-dev\d*|b\d*)?$", const.__version__) - if match is None: - raise ValueError(f"Failed to parse ESPHome version '{const.__version__}'") - return int(match.group(1)), int(match.group(2)), int(match.group(3)) - - # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 79bfc303b7..4a4e37e5c4 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2,6 +2,7 @@ import json import logging from pathlib import Path import string +from unittest.mock import patch from hypothesis import example, given, settings from hypothesis.strategies import builds, integers, ip_addresses, one_of, text @@ -2926,11 +2927,46 @@ def test_require_esphome_version_ok() -> None: assert cv.require_esphome_version(1, 0, 0)("test") == "test" +def test_require_esphome_version_accepts_version_object() -> None: + """The Version form matches require_framework_version's style.""" + assert cv.require_esphome_version(cv.Version(1, 0, 0))("test") == "test" + with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"): + cv.require_esphome_version(cv.Version(9999, 0, 0))("test") + + +def test_require_esphome_version_partial_ints_fail_at_call_site() -> None: + """Missing ints raise immediately instead of a TypeError inside the validator.""" + with pytest.raises(ValueError, match="needs a Version or"): + cv.require_esphome_version(2026, 8) + with pytest.raises(ValueError, match="needs a Version or"): + cv.require_esphome_version(2026) + + def test_require_esphome_version_too_old() -> None: with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"): cv.require_esphome_version(9999, 0, 0)("test") +@pytest.mark.parametrize("current", ["2026.8.0", "2026.8.0b1", "2026.8.0-dev20260801"]) +def test_require_esphome_version_prerelease_of_required_passes(current: str) -> None: + """A dev or beta build of the required version satisfies it. + + Pins the behavior of the old tuple comparison that dropped the + suffix, now expressed through Version ordering where the extra field + only breaks ties upward. + """ + with patch.object(cv, "ESPHOME_VERSION", current): + assert cv.require_esphome_version(2026, 8, 0)("test") == "test" + + +def test_require_esphome_version_older_prerelease_fails() -> None: + with ( + patch.object(cv, "ESPHOME_VERSION", "2026.7.0-dev20260701"), + pytest.raises(Invalid, match="at least ESPHome version 2026.8.0"), + ): + cv.require_esphome_version(2026, 8, 0)("test") + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # ---------------------------------------------------------------------------