[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.
This commit is contained in:
J. Nick Koston
2026-05-12 15:38:57 -05:00
parent 6a8a1d5256
commit 26d2d4f65d
3 changed files with 35 additions and 69 deletions
+14 -45
View File
@@ -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/<file>.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/<file>.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(
+4 -4
View File
@@ -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)
@@ -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()