Home Assistant Unofficial Reference 2024.12.1
check_config.py
Go to the documentation of this file.
1 """Script to check the configuration file."""
2 
3 from __future__ import annotations
4 
5 import argparse
6 import asyncio
7 from collections import OrderedDict
8 from collections.abc import Callable, Mapping, Sequence
9 from glob import glob
10 import logging
11 import os
12 from typing import Any
13 from unittest.mock import patch
14 
15 from homeassistant import core, loader
16 from homeassistant.config import get_default_config_dir
17 from homeassistant.config_entries import ConfigEntries
18 from homeassistant.exceptions import HomeAssistantError
19 from homeassistant.helpers import (
20  area_registry as ar,
21  device_registry as dr,
22  entity_registry as er,
23  issue_registry as ir,
24 )
25 from homeassistant.helpers.check_config import async_check_ha_config_file
26 from homeassistant.util.yaml import Secrets
27 import homeassistant.util.yaml.loader as yaml_loader
28 
29 # mypy: allow-untyped-calls, allow-untyped-defs
30 
31 REQUIREMENTS = ("colorlog==6.8.2",)
32 
33 _LOGGER = logging.getLogger(__name__)
34 MOCKS: dict[str, tuple[str, Callable]] = {
35  "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml),
36  "load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict),
37  "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml),
38 }
39 
40 PATCHES: dict[str, Any] = {}
41 
42 C_HEAD = "bold"
43 ERROR_STR = "General Errors"
44 WARNING_STR = "General Warnings"
45 
46 
47 def color(the_color, *args, reset=None):
48  """Color helper."""
49  # pylint: disable-next=import-outside-toplevel
50  from colorlog.escape_codes import escape_codes, parse_colors
51 
52  try:
53  if not args:
54  assert reset is None, "You cannot reset if nothing being printed"
55  return parse_colors(the_color)
56  return parse_colors(the_color) + " ".join(args) + escape_codes[reset or "reset"]
57  except KeyError as k:
58  raise ValueError(f"Invalid color {k!s} in {the_color}") from k
59 
60 
61 def run(script_args: list) -> int:
62  """Handle check config commandline script."""
63  parser = argparse.ArgumentParser(description="Check Home Assistant configuration.")
64  parser.add_argument("--script", choices=["check_config"])
65  parser.add_argument(
66  "-c",
67  "--config",
68  default=get_default_config_dir(),
69  help="Directory that contains the Home Assistant configuration",
70  )
71  parser.add_argument(
72  "-i",
73  "--info",
74  nargs="?",
75  default=None,
76  const="all",
77  help="Show a portion of the config",
78  )
79  parser.add_argument(
80  "-f", "--files", action="store_true", help="Show used configuration files"
81  )
82  parser.add_argument(
83  "-s", "--secrets", action="store_true", help="Show secret information"
84  )
85 
86  args, unknown = parser.parse_known_args()
87  if unknown:
88  print(color("red", "Unknown arguments:", ", ".join(unknown)))
89 
90  config_dir = os.path.join(os.getcwd(), args.config)
91 
92  print(color("bold", "Testing configuration at", config_dir))
93 
94  res = check(config_dir, args.secrets)
95 
96  domain_info: list[str] = []
97  if args.info:
98  domain_info = args.info.split(",")
99 
100  if args.files:
101  print(color(C_HEAD, "yaml files"), "(used /", color("red", "not used") + ")")
102  deps = os.path.join(config_dir, "deps")
103  yaml_files = [
104  f
105  for f in glob(os.path.join(config_dir, "**/*.yaml"), recursive=True)
106  if not f.startswith(deps)
107  ]
108 
109  for yfn in sorted(yaml_files):
110  the_color = "" if yfn in res["yaml_files"] else "red"
111  print(color(the_color, "-", yfn))
112 
113  if res["except"]:
114  print(color("bold_white", "Failed config"))
115  for domain, config in res["except"].items():
116  domain_info.append(domain)
117  print(" ", color("bold_red", domain + ":"), color("red", "", reset="red"))
118  dump_dict(config, reset="red")
119  print(color("reset"))
120 
121  if res["warn"]:
122  print(color("bold_white", "Incorrect config"))
123  for domain, config in res["warn"].items():
124  domain_info.append(domain)
125  print(
126  " ",
127  color("bold_yellow", domain + ":"),
128  color("yellow", "", reset="yellow"),
129  )
130  dump_dict(config, reset="yellow")
131  print(color("reset"))
132 
133  if domain_info:
134  if "all" in domain_info:
135  print(color("bold_white", "Successful config (all)"))
136  for domain, config in res["components"].items():
137  print(" ", color(C_HEAD, domain + ":"))
138  dump_dict(config)
139  else:
140  print(color("bold_white", "Successful config (partial)"))
141  for domain in domain_info:
142  if domain == ERROR_STR:
143  continue
144  print(" ", color(C_HEAD, domain + ":"))
145  dump_dict(res["components"].get(domain))
146 
147  if args.secrets:
148  flatsecret: dict[str, str] = {}
149 
150  for sfn, sdict in res["secret_cache"].items():
151  sss = []
152  for skey in sdict:
153  if skey in flatsecret:
154  _LOGGER.error(
155  "Duplicated secrets in files %s and %s", flatsecret[skey], sfn
156  )
157  flatsecret[skey] = sfn
158  sss.append(color("green", skey) if skey in res["secrets"] else skey)
159  print(color(C_HEAD, "Secrets from", sfn + ":"), ", ".join(sss))
160 
161  print(color(C_HEAD, "Used Secrets:"))
162  for skey, sval in res["secrets"].items():
163  if sval is None:
164  print(" -", skey + ":", color("red", "not found"))
165  continue
166  print(" -", skey + ":", sval)
167 
168  return len(res["except"])
169 
170 
171 def check(config_dir, secrets=False):
172  """Perform a check by mocking hass load functions."""
173  logging.getLogger("homeassistant.loader").setLevel(logging.CRITICAL)
174  res: dict[str, Any] = {
175  "yaml_files": OrderedDict(), # yaml_files loaded
176  "secrets": OrderedDict(), # secret cache and secrets loaded
177  "except": OrderedDict(), # critical exceptions raised (with config)
178  "warn": OrderedDict(), # non critical exceptions raised (with config)
179  #'components' is a HomeAssistantConfig
180  "secret_cache": {},
181  }
182 
183  # pylint: disable-next=possibly-unused-variable
184  def mock_load(filename, secrets=None):
185  """Mock hass.util.load_yaml to save config file names."""
186  res["yaml_files"][filename] = True
187  return MOCKS["load"][1](filename, secrets)
188 
189  # pylint: disable-next=possibly-unused-variable
190  def mock_secrets(ldr, node):
191  """Mock _get_secrets."""
192  try:
193  val = MOCKS["secrets"][1](ldr, node)
194  except HomeAssistantError:
195  val = None
196  res["secrets"][node.value] = val
197  return val
198 
199  # Patches with local mock functions
200  for key, val in MOCKS.items():
201  if not secrets and key == "secrets":
202  continue
203  # The * in the key is removed to find the mock_function (side_effect)
204  # This allows us to use one side_effect to patch multiple locations
205  mock_function = locals()[f"mock_{key.replace('*', '')}"]
206  PATCHES[key] = patch(val[0], side_effect=mock_function)
207 
208  # Start all patches
209  for pat in PATCHES.values():
210  pat.start()
211 
212  if secrets:
213  # Ensure !secrets point to the patched function
214  yaml_loader.add_constructor("!secret", yaml_loader.secret_yaml)
215 
216  def secrets_proxy(*args):
217  secrets = Secrets(*args)
218  res["secret_cache"] = secrets._cache # noqa: SLF001
219  return secrets
220 
221  try:
222  with patch.object(yaml_loader, "Secrets", secrets_proxy):
223  res["components"] = asyncio.run(async_check_config(config_dir))
224  res["secret_cache"] = {
225  str(key): val for key, val in res["secret_cache"].items()
226  }
227  for err in res["components"].errors:
228  domain = err.domain or ERROR_STR
229  res["except"].setdefault(domain, []).append(err.message)
230  if err.config:
231  res["except"].setdefault(domain, []).append(err.config)
232 
233  for err in res["components"].warnings:
234  domain = err.domain or WARNING_STR
235  res["warn"].setdefault(domain, []).append(err.message)
236  if err.config:
237  res["warn"].setdefault(domain, []).append(err.config)
238 
239  except Exception as err: # noqa: BLE001
240  print(color("red", "Fatal error while loading config:"), str(err))
241  res["except"].setdefault(ERROR_STR, []).append(str(err))
242  finally:
243  # Stop all patches
244  for pat in PATCHES.values():
245  pat.stop()
246  if secrets:
247  # Ensure !secrets point to the original function
248  yaml_loader.add_constructor("!secret", yaml_loader.secret_yaml)
249 
250  return res
251 
252 
253 async def async_check_config(config_dir):
254  """Check the HA config."""
255  hass = core.HomeAssistant(config_dir)
256  loader.async_setup(hass)
257  hass.config_entries = ConfigEntries(hass, {})
258  await ar.async_load(hass)
259  await dr.async_load(hass)
260  await er.async_load(hass)
261  await ir.async_load(hass, read_only=True)
262  components = await async_check_ha_config_file(hass)
263  await hass.async_stop(force=True)
264  return components
265 
266 
267 def line_info(obj, **kwargs):
268  """Display line config source."""
269  if hasattr(obj, "__config_file__"):
270  return color(
271  "cyan", f"[source {obj.__config_file__}:{obj.__line__ or '?'}]", **kwargs
272  )
273  return "?"
274 
275 
276 def dump_dict(layer, indent_count=3, listi=False, **kwargs):
277  """Display a dict.
278 
279  A friendly version of print yaml_loader.yaml.dump(config).
280  """
281 
282  def sort_dict_key(val):
283  """Return the dict key for sorting."""
284  key = str(val[0]).lower()
285  return "0" if key == "platform" else key
286 
287  indent_str = indent_count * " "
288  if listi or isinstance(layer, list):
289  indent_str = indent_str[:-1] + "-"
290  if isinstance(layer, Mapping):
291  for key, value in sorted(layer.items(), key=sort_dict_key):
292  if isinstance(value, (dict, list)):
293  print(indent_str, str(key) + ":", line_info(value, **kwargs))
294  dump_dict(value, indent_count + 2, **kwargs)
295  else:
296  print(indent_str, str(key) + ":", value, line_info(key, **kwargs))
297  indent_str = indent_count * " "
298  if isinstance(layer, Sequence):
299  for i in layer:
300  if isinstance(i, dict):
301  dump_dict(i, indent_count + 2, True, **kwargs)
302  else:
303  print(" ", indent_str, i)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
str get_default_config_dir()
Definition: config.py:135
HomeAssistantConfig async_check_ha_config_file(HomeAssistant hass)
Definition: check_config.py:88
def color(the_color, *args, reset=None)
Definition: check_config.py:47
def dump_dict(layer, indent_count=3, listi=False, **kwargs)
def check(config_dir, secrets=False)