From 26d2d4f65d2a8c83e5dfaac0fb58a7c9394b486d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 15:38:57 -0500 Subject: [PATCH] [core] Make validated-config cache automatic, no flag Drop the `--from-storage-json` CLI flag. `esphome upload` and `esphome logs` now always try the validated-config cache and transparently fall back to `read_config()` when it's missing, stale (YAML mtime > cache mtime), or corrupt. No caller change is required to benefit, and a cold cache never produces a worse outcome than today. `compile` continues to write the cache unconditionally so the next upload / logs against this YAML can skip validation; it itself always re-validates since code generation needs the fully validated config. --- esphome/__main__.py | 59 +++++-------------- esphome/writer.py | 8 +-- ...py => test_main_validated_config_cache.py} | 37 ++++++------ 3 files changed, 35 insertions(+), 69 deletions(-) rename tests/unit_tests/{test_main_from_storage_json.py => test_main_validated_config_cache.py} (89%) diff --git a/esphome/__main__.py b/esphome/__main__.py index 4358cdf1a36..435931cd285 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2117,16 +2117,6 @@ def parse_args(argv): help="Upload as bootloader (OTA).", action="store_true", ) - parser_upload.add_argument( - "--from-storage-json", - action="store_true", - help=( - "Skip YAML schema validation by loading device metadata from " - "the .esphome/storage/.json sidecar produced by the last " - "successful compile. Falls back to a full validation pass when " - "the sidecar is missing or older than the YAML." - ), - ) parser_logs = subparsers.add_parser( "logs", @@ -2154,16 +2144,6 @@ def parse_args(argv): action="store_true", help="Do not show entity state changes in log output.", ) - parser_logs.add_argument( - "--from-storage-json", - action="store_true", - help=( - "Skip YAML schema validation by loading device metadata from " - "the .esphome/storage/.json sidecar produced by the last " - "successful compile. Falls back to a full validation pass when " - "the sidecar is missing or older than the YAML." - ), - ) parser_discover = subparsers.add_parser( "discover", @@ -2439,44 +2419,33 @@ def run_esphome(argv): skip_external = args.command in ("logs", "clean") command_line_substitutions = dict(args.substitution) if args.substitution else {} - # Fast path for `upload --from-storage-json` and `logs --from-storage-json`: - # the caller already has a binary on disk and only needs the CLI to ship - # bytes to a device or stream logs back. Re-running the full - # `read_config()` pipeline (parse + schema validate + final-validate + - # external component refresh) for those two subcommands is dead work and - # produces a wall of "Reading configuration ..." log lines for every - # remote install. Reload the validated config that the last compile - # cached alongside the StorageJSON sidecar; fall back to full - # validation if the cache is missing or older than the YAML so a - # cold cache never produces a worse outcome. + # Fast path for `upload` and `logs`: the caller already has a binary + # on disk and only needs the CLI to ship bytes to a device or stream + # logs back. Re-running the full `read_config()` pipeline (parse + + # schema validate + final-validate + external component refresh) is + # dead work and produces a wall of "Reading configuration ..." log + # lines on every install. Reload the validated config the last + # compile cached alongside the StorageJSON sidecar; fall back to + # full validation if the cache is missing or older than the YAML so + # a cold cache never produces a worse outcome. config = None - if getattr(args, "from_storage_json", False) and args.command in ( - "upload", - "logs", - ): + if args.command in ("upload", "logs"): from esphome.storage_json import ( StorageJSON, ext_storage_path, load_compiled_config, ) - config = load_compiled_config(conf_path) - if config is not None: + cached = load_compiled_config(conf_path) + if cached is not None: storage = StorageJSON.load(ext_storage_path(conf_path.name)) - if storage is None: - config = None - else: + if storage is not None: storage.apply_to_core() + config = cached _LOGGER.info( "Loaded validated config cache for %s, skipping validation.", conf_path.name, ) - if config is None: - _LOGGER.warning( - "Validated config cache for %s is missing or older than the " - "YAML; falling back to full config validation.", - conf_path, - ) if config is None: config = read_config( diff --git a/esphome/writer.py b/esphome/writer.py index 67df3d1f225..8efefe68baf 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -110,10 +110,10 @@ def update_storage_json() -> None: old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) - # Always refresh the validated-config cache so `esphome upload - # --from-storage-json` and `esphome logs --from-storage-json` can - # skip re-validating after this compile. Lives in its own file - # next to the sidecar; mtime gates staleness on the read side. + # Refresh the validated-config cache. `esphome upload` and + # `esphome logs` look for it next time they run and skip the full + # validation pipeline when it's present and fresh (mtime >= YAML + # mtime). Cheap to write, big win to read. if CORE.config is not None: save_compiled_config(CORE.config) diff --git a/tests/unit_tests/test_main_from_storage_json.py b/tests/unit_tests/test_main_validated_config_cache.py similarity index 89% rename from tests/unit_tests/test_main_from_storage_json.py rename to tests/unit_tests/test_main_validated_config_cache.py index 2433511f7fd..a5ecbe59c95 100644 --- a/tests/unit_tests/test_main_from_storage_json.py +++ b/tests/unit_tests/test_main_validated_config_cache.py @@ -1,4 +1,4 @@ -"""Tests for the `--from-storage-json` validated-config cache fast path.""" +"""Tests for the validated-config cache fast path used by upload/logs.""" from __future__ import annotations @@ -169,10 +169,10 @@ def test_storage_json_apply_to_core_populates_target_platform(tmp_path: Path) -> @pytest.mark.parametrize("command", ["upload", "logs"]) -def test_run_esphome_from_storage_json_skips_read_config( +def test_run_esphome_upload_and_logs_use_cache_when_fresh( command: str, fresh_cache_files: Path ) -> None: - """`--from-storage-json` makes the dispatcher skip read_config().""" + """When the cache is fresh, upload/logs skip read_config() entirely.""" yaml_path = fresh_cache_files captured = {} @@ -185,9 +185,7 @@ def test_run_esphome_from_storage_json_skips_read_config( patch("esphome.__main__.read_config") as mock_read, patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}), ): - result = run_esphome( - ["esphome", command, "--from-storage-json", str(yaml_path)] - ) + result = run_esphome(["esphome", command, str(yaml_path)]) mock_read.assert_not_called() assert result == 0 @@ -197,7 +195,7 @@ def test_run_esphome_from_storage_json_skips_read_config( @pytest.mark.parametrize("command", ["upload", "logs"]) -def test_run_esphome_from_storage_json_falls_back_when_missing( +def test_run_esphome_upload_and_logs_fall_back_when_no_cache( tmp_path: Path, command: str ) -> None: """With no cache on disk, the dispatcher falls back to read_config().""" @@ -211,17 +209,13 @@ def test_run_esphome_from_storage_json_falls_back_when_missing( {command: lambda args, config: 0}, ), ): - result = run_esphome( - ["esphome", command, "--from-storage-json", str(yaml_path)] - ) + result = run_esphome(["esphome", command, str(yaml_path)]) mock_read.assert_called_once() assert result == 2 -def test_run_esphome_from_storage_json_falls_back_when_stale( - tmp_path: Path, -) -> None: +def test_run_esphome_upload_falls_back_when_cache_stale(tmp_path: Path) -> None: """If YAML mtime > cache mtime, dispatcher falls back to read_config().""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") @@ -241,24 +235,27 @@ def test_run_esphome_from_storage_json_falls_back_when_stale( {"upload": lambda args, config: 0}, ), ): - run_esphome(["esphome", "upload", "--from-storage-json", str(yaml_path)]) + run_esphome(["esphome", "upload", str(yaml_path)]) mock_read.assert_called_once() -def test_run_esphome_without_flag_still_calls_read_config( - fresh_cache_files: Path, -) -> None: - """Sanity: omitting the flag preserves the current behaviour.""" +def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None: + """`compile` always re-validates, even with a fresh cache on disk. + + The fast path is only for upload / logs -- compile is what writes + the cache in the first place, and it needs a fully validated + config to drive code generation. + """ yaml_path = fresh_cache_files with ( patch("esphome.__main__.read_config", return_value=None) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", - {"upload": lambda args, config: 0}, + {"compile": lambda args, config: 0}, ), ): - run_esphome(["esphome", "upload", str(yaml_path)]) + run_esphome(["esphome", "compile", str(yaml_path)]) mock_read.assert_called_once()