core/homeassistant/scripts/check_config.py

274 lines
9.3 KiB
Python
Raw Normal View History

"""Script to check the configuration file."""
2021-03-17 20:46:07 +00:00
from __future__ import annotations
import argparse
import asyncio
from collections import OrderedDict
from collections.abc import Mapping, Sequence
from glob import glob
import logging
import os
2021-03-17 20:46:07 +00:00
from typing import Any, Callable
from unittest.mock import patch
2021-03-02 20:58:53 +00:00
from homeassistant import core
from homeassistant.config import get_default_config_dir
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.check_config import async_check_ha_config_file
2021-03-02 20:58:53 +00:00
from homeassistant.util.yaml import Secrets
import homeassistant.util.yaml.loader as yaml_loader
# mypy: allow-untyped-calls, allow-untyped-defs
2021-02-02 00:01:19 +00:00
REQUIREMENTS = ("colorlog==4.7.2",)
_LOGGER = logging.getLogger(__name__)
# pylint: disable=protected-access
2021-03-17 20:46:07 +00:00
MOCKS: dict[str, tuple[str, Callable]] = {
2019-07-31 19:25:30 +00:00
"load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml),
"load*": ("homeassistant.config.load_yaml", yaml_loader.load_yaml),
"secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml),
}
2021-03-17 20:46:07 +00:00
PATCHES: dict[str, Any] = {}
2019-07-31 19:25:30 +00:00
C_HEAD = "bold"
ERROR_STR = "General Errors"
def color(the_color, *args, reset=None):
"""Color helper."""
# pylint: disable=import-outside-toplevel
from colorlog.escape_codes import escape_codes, parse_colors
2019-07-31 19:25:30 +00:00
try:
if not args:
assert reset is None, "You cannot reset if nothing being printed"
return parse_colors(the_color)
2019-07-31 19:25:30 +00:00
return parse_colors(the_color) + " ".join(args) + escape_codes[reset or "reset"]
except KeyError as k:
raise ValueError(f"Invalid color {k!s} in {the_color}") from k
2021-03-17 20:46:07 +00:00
def run(script_args: list) -> int:
"""Handle check config commandline script."""
2019-07-31 19:25:30 +00:00
parser = argparse.ArgumentParser(description="Check Home Assistant configuration.")
parser.add_argument("--script", choices=["check_config"])
parser.add_argument(
2019-07-31 19:25:30 +00:00
"-c",
"--config",
default=get_default_config_dir(),
2019-07-31 19:25:30 +00:00
help="Directory that contains the Home Assistant configuration",
)
parser.add_argument(
2019-07-31 19:25:30 +00:00
"-i",
"--info",
nargs="?",
default=None,
const="all",
help="Show a portion of the config",
)
parser.add_argument(
2019-07-31 19:25:30 +00:00
"-f", "--files", action="store_true", help="Show used configuration files"
)
parser.add_argument(
2019-07-31 19:25:30 +00:00
"-s", "--secrets", action="store_true", help="Show secret information"
)
args, unknown = parser.parse_known_args()
if unknown:
2019-07-31 19:25:30 +00:00
print(color("red", "Unknown arguments:", ", ".join(unknown)))
config_dir = os.path.join(os.getcwd(), args.config)
2019-07-31 19:25:30 +00:00
print(color("bold", "Testing configuration at", config_dir))
res = check(config_dir, args.secrets)
2021-03-17 20:46:07 +00:00
domain_info: list[str] = []
if args.info:
2019-07-31 19:25:30 +00:00
domain_info = args.info.split(",")
if args.files:
2019-07-31 19:25:30 +00:00
print(color(C_HEAD, "yaml files"), "(used /", color("red", "not used") + ")")
deps = os.path.join(config_dir, "deps")
yaml_files = [
f
for f in glob(os.path.join(config_dir, "**/*.yaml"), recursive=True)
if not f.startswith(deps)
]
2018-03-15 11:10:54 +00:00
for yfn in sorted(yaml_files):
2019-07-31 19:25:30 +00:00
the_color = "" if yfn in res["yaml_files"] else "red"
print(color(the_color, "-", yfn))
2019-07-31 19:25:30 +00:00
if res["except"]:
print(color("bold_white", "Failed config"))
for domain, config in res["except"].items():
domain_info.append(domain)
2019-07-31 19:25:30 +00:00
print(" ", color("bold_red", domain + ":"), color("red", "", reset="red"))
dump_dict(config, reset="red")
print(color("reset"))
if domain_info:
2019-07-31 19:25:30 +00:00
if "all" in domain_info:
print(color("bold_white", "Successful config (all)"))
for domain, config in res["components"].items():
print(" ", color(C_HEAD, domain + ":"))
dump_dict(config)
else:
2019-07-31 19:25:30 +00:00
print(color("bold_white", "Successful config (partial)"))
for domain in domain_info:
if domain == ERROR_STR:
continue
2019-07-31 19:25:30 +00:00
print(" ", color(C_HEAD, domain + ":"))
dump_dict(res["components"].get(domain))
if args.secrets:
2021-03-17 20:46:07 +00:00
flatsecret: dict[str, str] = {}
2019-07-31 19:25:30 +00:00
for sfn, sdict in res["secret_cache"].items():
sss = []
for skey in sdict:
if skey in flatsecret:
2019-07-31 19:25:30 +00:00
_LOGGER.error(
"Duplicated secrets in files %s and %s", flatsecret[skey], sfn
)
flatsecret[skey] = sfn
2019-07-31 19:25:30 +00:00
sss.append(color("green", skey) if skey in res["secrets"] else skey)
print(color(C_HEAD, "Secrets from", sfn + ":"), ", ".join(sss))
2019-07-31 19:25:30 +00:00
print(color(C_HEAD, "Used Secrets:"))
for skey, sval in res["secrets"].items():
if sval is None:
2019-07-31 19:25:30 +00:00
print(" -", skey + ":", color("red", "not found"))
continue
print(" -", skey + ":", sval)
2019-07-31 19:25:30 +00:00
return len(res["except"])
def check(config_dir, secrets=False):
"""Perform a check by mocking hass load functions."""
2019-07-31 19:25:30 +00:00
logging.getLogger("homeassistant.loader").setLevel(logging.CRITICAL)
2021-03-17 20:46:07 +00:00
res: dict[str, Any] = {
2019-07-31 19:25:30 +00:00
"yaml_files": OrderedDict(), # yaml_files loaded
"secrets": OrderedDict(), # secret cache and secrets loaded
"except": OrderedDict(), # exceptions raised (with config)
#'components' is a HomeAssistantConfig # noqa: E265
2021-03-02 20:58:53 +00:00
"secret_cache": {},
}
# pylint: disable=possibly-unused-variable
2021-03-02 20:58:53 +00:00
def mock_load(filename, secrets=None):
"""Mock hass.util.load_yaml to save config file names."""
2019-07-31 19:25:30 +00:00
res["yaml_files"][filename] = True
2021-03-02 20:58:53 +00:00
return MOCKS["load"][1](filename, secrets)
# pylint: disable=possibly-unused-variable
def mock_secrets(ldr, node):
"""Mock _get_secrets."""
try:
2019-07-31 19:25:30 +00:00
val = MOCKS["secrets"][1](ldr, node)
except HomeAssistantError:
val = None
2019-07-31 19:25:30 +00:00
res["secrets"][node.value] = val
return val
# Patches with local mock functions
for key, val in MOCKS.items():
2019-07-31 19:25:30 +00:00
if not secrets and key == "secrets":
continue
# The * in the key is removed to find the mock_function (side_effect)
# This allows us to use one side_effect to patch multiple locations
mock_function = locals()[f"mock_{key.replace('*', '')}"]
PATCHES[key] = patch(val[0], side_effect=mock_function)
# Start all patches
for pat in PATCHES.values():
pat.start()
if secrets:
# Ensure !secrets point to the patched function
2021-03-02 20:58:53 +00:00
yaml_loader.SafeLineLoader.add_constructor("!secret", yaml_loader.secret_yaml)
def secrets_proxy(*args):
secrets = Secrets(*args)
res["secret_cache"] = secrets._cache
return secrets
try:
2021-03-02 20:58:53 +00:00
with patch.object(yaml_loader, "Secrets", secrets_proxy):
res["components"] = asyncio.run(async_check_config(config_dir))
res["secret_cache"] = {
str(key): val for key, val in res["secret_cache"].items()
}
2019-07-31 19:25:30 +00:00
for err in res["components"].errors:
domain = err.domain or ERROR_STR
2019-07-31 19:25:30 +00:00
res["except"].setdefault(domain, []).append(err.message)
if err.config:
2019-07-31 19:25:30 +00:00
res["except"].setdefault(domain, []).append(err.config)
except Exception as err: # pylint: disable=broad-except
2019-07-31 19:25:30 +00:00
print(color("red", "Fatal error while loading config:"), str(err))
res["except"].setdefault(ERROR_STR, []).append(str(err))
finally:
# Stop all patches
for pat in PATCHES.values():
pat.stop()
if secrets:
# Ensure !secrets point to the original function
2021-03-02 20:58:53 +00:00
yaml_loader.SafeLineLoader.add_constructor(
2019-07-31 19:25:30 +00:00
"!secret", yaml_loader.secret_yaml
)
return res
async def async_check_config(config_dir):
"""Check the HA config."""
hass = core.HomeAssistant()
hass.config.config_dir = config_dir
components = await async_check_ha_config_file(hass)
await hass.async_stop(force=True)
return components
def line_info(obj, **kwargs):
"""Display line config source."""
2019-07-31 19:25:30 +00:00
if hasattr(obj, "__config_file__"):
return color(
"cyan", f"[source {obj.__config_file__}:{obj.__line__ or '?'}]", **kwargs
2019-07-31 19:25:30 +00:00
)
return "?"
def dump_dict(layer, indent_count=3, listi=False, **kwargs):
"""Display a dict.
A friendly version of print yaml_loader.yaml.dump(config).
"""
2019-07-31 19:25:30 +00:00
def sort_dict_key(val):
"""Return the dict key for sorting."""
key = str(val[0]).lower()
2019-07-31 19:25:30 +00:00
return "0" if key == "platform" else key
2019-07-31 19:25:30 +00:00
indent_str = indent_count * " "
if listi or isinstance(layer, list):
2019-07-31 19:25:30 +00:00
indent_str = indent_str[:-1] + "-"
if isinstance(layer, Mapping):
for key, value in sorted(layer.items(), key=sort_dict_key):
if isinstance(value, (dict, list)):
2019-07-31 19:25:30 +00:00
print(indent_str, str(key) + ":", line_info(value, **kwargs))
dump_dict(value, indent_count + 2)
else:
2019-07-31 19:25:30 +00:00
print(indent_str, str(key) + ":", value)
indent_str = indent_count * " "
if isinstance(layer, Sequence):
for i in layer:
if isinstance(i, dict):
dump_dict(i, indent_count + 2, True)
else:
2019-07-31 19:25:30 +00:00
print(" ", indent_str, i)