diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 5ce968f20d..ad9844a3bf 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -693,12 +693,13 @@ class EsphomeCore: """Get the FNV-1a 32-bit hash of the config. The hash is computed lazily and cached for performance. + Uses sort_keys=True to ensure deterministic ordering. """ if self._config_hash is None: from esphome import yaml_util from esphome.helpers import fnv1a_32bit_hash - config_str = yaml_util.dump(self.config, show_secrets=True) + config_str = yaml_util.dump(self.config, show_secrets=True, sort_keys=True) self._config_hash = fnv1a_32bit_hash(config_str) return self._config_hash diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 359b72b48f..bba4bbf487 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from contextlib import suppress import functools import inspect from io import BytesIO, TextIOBase, TextIOWrapper @@ -501,13 +502,17 @@ def _load_yaml_internal_with_type( loader.dispose() -def dump(dict_, show_secrets=False): +def dump(dict_, show_secrets=False, sort_keys=False): """Dump YAML to a string and remove null.""" if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() return yaml.dump( - dict_, default_flow_style=False, allow_unicode=True, Dumper=ESPHomeDumper + dict_, + default_flow_style=False, + allow_unicode=True, + Dumper=ESPHomeDumper, + sort_keys=sort_keys, ) @@ -543,6 +548,9 @@ class ESPHomeDumper(yaml.SafeDumper): best_style = True if hasattr(mapping, "items"): mapping = list(mapping.items()) + if self.sort_keys: + with suppress(TypeError): + mapping = sorted(mapping) for item_key, item_value in mapping: node_key = self.represent_data(item_key) node_value = self.represent_data(item_value) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index eac0ceabb8..c8cb3e144f 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -278,3 +278,31 @@ def test_secret_values_tracking(fixture_path: Path) -> None: assert yaml_util._SECRET_VALUES["super_secret_wifi"] == "wifi_password" assert "0123456789abcdef" in yaml_util._SECRET_VALUES assert yaml_util._SECRET_VALUES["0123456789abcdef"] == "api_key" + + +def test_dump_sort_keys() -> None: + """Test that dump with sort_keys=True produces sorted output.""" + # Create a dict with unsorted keys + data = { + "zebra": 1, + "alpha": 2, + "nested": { + "z_key": "z_value", + "a_key": "a_value", + }, + } + + # Without sort_keys, keys are in insertion order + unsorted = yaml_util.dump(data, sort_keys=False) + lines_unsorted = unsorted.strip().split("\n") + # First key should be "zebra" (insertion order) + assert lines_unsorted[0].startswith("zebra:") + + # With sort_keys, keys are alphabetically sorted + sorted_dump = yaml_util.dump(data, sort_keys=True) + lines_sorted = sorted_dump.strip().split("\n") + # First key should be "alpha" (alphabetical order) + assert lines_sorted[0].startswith("alpha:") + # nested keys should also be sorted + assert "a_key:" in sorted_dump + assert sorted_dump.index("a_key:") < sorted_dump.index("z_key:")