core/homeassistant/util/yaml/loader.py

327 lines
11 KiB
Python
Raw Normal View History

"""Custom loader."""
2021-03-17 20:46:07 +00:00
from __future__ import annotations
from collections import OrderedDict
from collections.abc import Iterator
import fnmatch
2016-01-24 06:37:15 +00:00
import logging
import os
2021-03-02 20:58:53 +00:00
from pathlib import Path
2021-04-25 00:39:24 +00:00
from typing import Any, TextIO, TypeVar, Union, overload
2016-01-24 06:37:15 +00:00
import yaml
from homeassistant.exceptions import HomeAssistantError
from .const import SECRET_YAML
from .objects import Input, NodeListClass, NodeStrClass
# mypy: allow-untyped-calls, no-warn-return-any
2021-04-25 00:39:24 +00:00
JSON_TYPE = Union[list, dict, str] # pylint: disable=invalid-name
DICT_T = TypeVar("DICT_T", bound=dict) # pylint: disable=invalid-name
2016-01-24 06:37:15 +00:00
_LOGGER = logging.getLogger(__name__)
2016-01-24 06:37:15 +00:00
2021-03-02 20:58:53 +00:00
class Secrets:
"""Store secrets while loading YAML."""
def __init__(self, config_dir: Path) -> None:
2021-03-02 20:58:53 +00:00
"""Initialize secrets."""
self.config_dir = config_dir
2021-03-17 20:46:07 +00:00
self._cache: dict[Path, dict[str, str]] = {}
2021-03-02 20:58:53 +00:00
def get(self, requester_path: str, secret: str) -> str:
"""Return the value of a secret."""
current_path = Path(requester_path)
secret_dir = current_path
while True:
secret_dir = secret_dir.parent
try:
secret_dir.relative_to(self.config_dir)
except ValueError:
# We went above the config dir
break
secrets = self._load_secret_yaml(secret_dir)
if secret in secrets:
_LOGGER.debug(
"Secret %s retrieved from secrets.yaml in folder %s",
secret,
secret_dir,
)
return secrets[secret]
raise HomeAssistantError(f"Secret {secret} not defined")
2021-03-17 20:46:07 +00:00
def _load_secret_yaml(self, secret_dir: Path) -> dict[str, str]:
2021-03-02 20:58:53 +00:00
"""Load the secrets yaml from path."""
secret_path = secret_dir / SECRET_YAML
if secret_path in self._cache:
return self._cache[secret_path]
_LOGGER.debug("Loading %s", secret_path)
try:
secrets = load_yaml(str(secret_path))
if not isinstance(secrets, dict):
raise HomeAssistantError("Secrets is not a dictionary")
if "logger" in secrets:
logger = str(secrets["logger"]).lower()
if logger == "debug":
_LOGGER.setLevel(logging.DEBUG)
else:
_LOGGER.error(
"Error in secrets.yaml: 'logger: debug' expected, but 'logger: %s' found",
2021-03-02 20:58:53 +00:00
logger,
)
del secrets["logger"]
except FileNotFoundError:
secrets = {}
self._cache[secret_path] = secrets
return secrets
class SafeLineLoader(yaml.SafeLoader):
"""Loader class that keeps track of line numbers."""
2021-03-17 20:46:07 +00:00
def __init__(self, stream: Any, secrets: Secrets | None = None) -> None:
2021-03-02 20:58:53 +00:00
"""Initialize a safe line loader."""
super().__init__(stream)
self.secrets = secrets
2021-06-16 12:35:32 +00:00
def compose_node(self, parent: yaml.nodes.Node, index: int) -> yaml.nodes.Node: # type: ignore[override]
"""Annotate a node with the first line it was seen."""
last_line: int = self.line
2021-06-16 12:35:32 +00:00
node: yaml.nodes.Node = super().compose_node(parent, index) # type: ignore[assignment]
node.__line__ = last_line + 1 # type: ignore
return node
2021-03-17 20:46:07 +00:00
def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE:
"""Load a YAML file."""
try:
2019-07-31 19:25:30 +00:00
with open(fname, encoding="utf-8") as conf_file:
2021-03-02 20:58:53 +00:00
return parse_yaml(conf_file, secrets)
except UnicodeDecodeError as exc:
_LOGGER.error("Unable to read file %s: %s", fname, exc)
raise HomeAssistantError(exc) from exc
2021-03-17 20:46:07 +00:00
def parse_yaml(content: str | TextIO, secrets: Secrets | None = None) -> JSON_TYPE:
"""Load a YAML file."""
try:
# If configuration file is empty YAML returns None
# We convert that to an empty dict
2021-03-02 20:58:53 +00:00
return (
yaml.load(content, Loader=lambda stream: SafeLineLoader(stream, secrets))
or OrderedDict()
)
except yaml.YAMLError as exc:
_LOGGER.error(str(exc))
raise HomeAssistantError(exc) from exc
@overload
2019-07-31 19:25:30 +00:00
def _add_reference(
2021-03-17 20:46:07 +00:00
obj: list | NodeListClass, loader: SafeLineLoader, node: yaml.nodes.Node
2019-07-31 19:25:30 +00:00
) -> NodeListClass:
...
@overload
2019-07-31 19:25:30 +00:00
def _add_reference(
2021-03-17 20:46:07 +00:00
obj: str | NodeStrClass, loader: SafeLineLoader, node: yaml.nodes.Node
2019-07-31 19:25:30 +00:00
) -> NodeStrClass:
...
@overload
2019-07-31 19:25:30 +00:00
def _add_reference(
2021-03-02 20:58:53 +00:00
obj: DICT_T, loader: SafeLineLoader, node: yaml.nodes.Node
2019-07-31 19:25:30 +00:00
) -> DICT_T:
...
2020-08-27 11:56:20 +00:00
def _add_reference(obj, loader: SafeLineLoader, node: yaml.nodes.Node): # type: ignore
"""Add file reference information to an object."""
if isinstance(obj, list):
obj = NodeListClass(obj)
if isinstance(obj, str):
obj = NodeStrClass(obj)
2019-07-31 19:25:30 +00:00
setattr(obj, "__config_file__", loader.name)
setattr(obj, "__line__", node.start_mark.line)
return obj
2019-07-31 19:25:30 +00:00
def _include_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE:
2016-03-07 22:20:48 +00:00
"""Load another YAML file and embeds it using the !include tag.
2016-01-24 06:37:15 +00:00
Example:
device_tracker: !include device_tracker.yaml
2016-01-24 06:37:15 +00:00
"""
fname = os.path.join(os.path.dirname(loader.name), node.value)
try:
2021-03-02 20:58:53 +00:00
return _add_reference(load_yaml(fname, loader.secrets), loader, node)
except FileNotFoundError as exc:
raise HomeAssistantError(
f"{node.start_mark}: Unable to read file {fname}."
) from exc
2016-01-24 06:37:15 +00:00
def _is_file_valid(name: str) -> bool:
"""Decide if a file is valid."""
2019-07-31 19:25:30 +00:00
return not name.startswith(".")
def _find_files(directory: str, pattern: str) -> Iterator[str]:
"""Recursively load files in a directory."""
for root, dirs, files in os.walk(directory, topdown=True):
dirs[:] = [d for d in dirs if _is_file_valid(d)]
for basename in sorted(files):
if _is_file_valid(basename) and fnmatch.fnmatch(basename, pattern):
filename = os.path.join(root, basename)
yield filename
2019-07-31 19:25:30 +00:00
def _include_dir_named_yaml(
loader: SafeLineLoader, node: yaml.nodes.Node
) -> OrderedDict:
"""Load multiple files from directory as a dictionary."""
mapping: OrderedDict = OrderedDict()
loc = os.path.join(os.path.dirname(loader.name), node.value)
2019-07-31 19:25:30 +00:00
for fname in _find_files(loc, "*.yaml"):
filename = os.path.splitext(os.path.basename(fname))[0]
if os.path.basename(fname) == SECRET_YAML:
continue
2021-03-02 20:58:53 +00:00
mapping[filename] = load_yaml(fname, loader.secrets)
return _add_reference(mapping, loader, node)
2019-07-31 19:25:30 +00:00
def _include_dir_merge_named_yaml(
loader: SafeLineLoader, node: yaml.nodes.Node
) -> OrderedDict:
"""Load multiple files from directory as a merged dictionary."""
mapping: OrderedDict = OrderedDict()
loc = os.path.join(os.path.dirname(loader.name), node.value)
2019-07-31 19:25:30 +00:00
for fname in _find_files(loc, "*.yaml"):
if os.path.basename(fname) == SECRET_YAML:
continue
2021-03-02 20:58:53 +00:00
loaded_yaml = load_yaml(fname, loader.secrets)
if isinstance(loaded_yaml, dict):
mapping.update(loaded_yaml)
return _add_reference(mapping, loader, node)
2019-07-31 19:25:30 +00:00
def _include_dir_list_yaml(
loader: SafeLineLoader, node: yaml.nodes.Node
2021-03-17 20:46:07 +00:00
) -> list[JSON_TYPE]:
"""Load multiple files from directory as a list."""
loc = os.path.join(os.path.dirname(loader.name), node.value)
2019-07-31 19:25:30 +00:00
return [
load_yaml(f, loader.secrets)
2019-07-31 19:25:30 +00:00
for f in _find_files(loc, "*.yaml")
if os.path.basename(f) != SECRET_YAML
]
2019-07-31 19:25:30 +00:00
def _include_dir_merge_list_yaml(
loader: SafeLineLoader, node: yaml.nodes.Node
) -> JSON_TYPE:
"""Load multiple files from directory as a merged list."""
loc: str = os.path.join(os.path.dirname(loader.name), node.value)
2021-03-17 20:46:07 +00:00
merged_list: list[JSON_TYPE] = []
2019-07-31 19:25:30 +00:00
for fname in _find_files(loc, "*.yaml"):
if os.path.basename(fname) == SECRET_YAML:
continue
2021-03-02 20:58:53 +00:00
loaded_yaml = load_yaml(fname, loader.secrets)
if isinstance(loaded_yaml, list):
merged_list.extend(loaded_yaml)
return _add_reference(merged_list, loader, node)
2019-07-31 19:25:30 +00:00
def _ordered_dict(loader: SafeLineLoader, node: yaml.nodes.MappingNode) -> OrderedDict:
"""Load YAML mappings into an ordered dictionary to preserve key order."""
2016-01-24 06:37:15 +00:00
loader.flatten_mapping(node)
nodes = loader.construct_pairs(node)
2016-01-24 06:37:15 +00:00
2021-03-17 20:46:07 +00:00
seen: dict = {}
for (key, _), (child_node, _) in zip(nodes, node.value):
line = child_node.start_mark.line
try:
hash(key)
except TypeError as exc:
2019-07-31 19:25:30 +00:00
fname = getattr(loader.stream, "name", "")
raise yaml.MarkedYAMLError(
context=f'invalid key: "{key}"',
2021-06-16 12:35:32 +00:00
context_mark=yaml.Mark(fname, 0, line, -1, None, None), # type: ignore[arg-type]
) from exc
if key in seen:
2019-07-31 19:25:30 +00:00
fname = getattr(loader.stream, "name", "")
_LOGGER.warning(
'YAML file %s contains duplicate key "%s". Check lines %d and %d',
2019-07-31 19:25:30 +00:00
fname,
key,
seen[key],
line,
)
seen[key] = line
return _add_reference(OrderedDict(nodes), loader, node)
def _construct_seq(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE:
"""Add line number and file name to Load YAML sequence."""
2019-10-29 06:32:34 +00:00
(obj,) = loader.construct_yaml_seq(node)
return _add_reference(obj, loader, node)
2016-01-24 06:37:15 +00:00
2019-07-31 19:25:30 +00:00
def _env_var_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> str:
"""Load environment variables and embed it into the configuration YAML."""
args = node.value.split()
# Check for a default value
if len(args) > 1:
2019-07-31 19:25:30 +00:00
return os.getenv(args[0], " ".join(args[1:]))
if args[0] in os.environ:
return os.environ[args[0]]
_LOGGER.error("Environment variable %s not defined", node.value)
raise HomeAssistantError(node.value)
2019-07-31 19:25:30 +00:00
def secret_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE:
"""Load secrets and embed it into the configuration YAML."""
2021-03-02 20:58:53 +00:00
if loader.secrets is None:
raise HomeAssistantError("Secrets not supported in this YAML file")
2021-03-02 20:58:53 +00:00
return loader.secrets.get(loader.name, node.value)
2016-11-19 05:47:59 +00:00
2021-03-02 20:58:53 +00:00
SafeLineLoader.add_constructor("!include", _include_yaml)
SafeLineLoader.add_constructor(
2019-07-31 19:25:30 +00:00
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _ordered_dict
)
2021-03-02 20:58:53 +00:00
SafeLineLoader.add_constructor(
2019-07-31 19:25:30 +00:00
yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq
)
2021-03-02 20:58:53 +00:00
SafeLineLoader.add_constructor("!env_var", _env_var_yaml)
SafeLineLoader.add_constructor("!secret", secret_yaml)
SafeLineLoader.add_constructor("!include_dir_list", _include_dir_list_yaml)
SafeLineLoader.add_constructor("!include_dir_merge_list", _include_dir_merge_list_yaml)
SafeLineLoader.add_constructor("!include_dir_named", _include_dir_named_yaml)
SafeLineLoader.add_constructor(
2019-07-31 19:25:30 +00:00
"!include_dir_merge_named", _include_dir_merge_named_yaml
)
2021-03-02 20:58:53 +00:00
SafeLineLoader.add_constructor("!input", Input.from_node)