• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2022 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Yaml config file loader mixin."""
15
16import os
17import logging
18from pathlib import Path
19from typing import Any, Dict, List, Optional, Union
20
21import yaml
22
23_LOG = logging.getLogger(__package__)
24
25
26class MissingConfigTitle(Exception):
27    """Exception for when an existing YAML file is missing config_title."""
28
29
30class YamlConfigLoaderMixin:
31    """Yaml Config file loader mixin.
32
33    Use this mixin to load yaml file settings and save them into
34    ``self._config``. For example:
35
36    ::
37
38       class ConsolePrefs(YamlConfigLoaderMixin):
39           def __init__(self) -> None:
40               self.config_init(
41                   config_section_title='pw_console',
42                   project_file=Path('project_file.yaml'),
43                   project_user_file=Path('project_user_file.yaml'),
44                   user_file=Path('~/user_file.yaml'),
45                   default_config={},
46                   environment_var='PW_CONSOLE_CONFIG_FILE',
47               )
48
49    """
50
51    def config_init(
52        self,
53        config_section_title: str,
54        project_file: Optional[Union[Path, bool]] = None,
55        project_user_file: Optional[Union[Path, bool]] = None,
56        user_file: Optional[Union[Path, bool]] = None,
57        default_config: Optional[Dict[Any, Any]] = None,
58        environment_var: Optional[str] = None,
59    ) -> None:
60        """Call this to load YAML config files in order of precedence.
61
62        The following files are loaded in this order:
63        1. project_file
64        2. project_user_file
65        3. user_file
66
67        Lastly, if a valid file path is specified at
68        ``os.environ[environment_var]`` then load that file overriding all
69        config options.
70
71        Args:
72            config_section_title: String name of this config section. For
73                example: ``pw_console`` or ``pw_watch``. In the YAML file this
74                is represented by a ``config_title`` key.
75
76                ::
77
78                   ---
79                   config_title: pw_console
80
81            project_file: Project level config file. This is intended to be a
82                file living somewhere under a project folder and is checked into
83                the repo. It serves as a base config all developers can inherit
84                from.
85            project_user_file: User's personal config file for a specific
86                project. This can be a file that lives in a project folder that
87                is git-ignored and not checked into the repo.
88            user_file: A global user based config file. This is typically a file
89                in the users home directory and settings here apply to all
90                projects.
91            default_config: A Python dict representing the base default
92                config. This dict will be applied as a starting point before
93                loading any yaml files.
94            environment_var: The name of an environment variable to check for a
95                config file. If a config file exists there it will be loaded on
96                top of the default_config ignoring project and user files.
97        """
98
99        self._config_section_title: str = config_section_title
100        self.default_config = default_config if default_config else {}
101        self.reset_config()
102
103        if project_file and isinstance(project_file, Path):
104            self.project_file = Path(
105                os.path.expandvars(str(project_file.expanduser()))
106            )
107            self.load_config_file(self.project_file)
108
109        if project_user_file and isinstance(project_user_file, Path):
110            self.project_user_file = Path(
111                os.path.expandvars(str(project_user_file.expanduser()))
112            )
113            self.load_config_file(self.project_user_file)
114
115        if user_file and isinstance(user_file, Path):
116            self.user_file = Path(
117                os.path.expandvars(str(user_file.expanduser()))
118            )
119            self.load_config_file(self.user_file)
120
121        # Check for a config file specified by an environment variable.
122        if environment_var is None:
123            return
124        environment_config = os.environ.get(environment_var, None)
125        if environment_config:
126            env_file_path = Path(environment_config)
127            if not env_file_path.is_file():
128                raise FileNotFoundError(
129                    f'Cannot load config file: {env_file_path}'
130                )
131            self.reset_config()
132            self.load_config_file(env_file_path)
133
134    def _update_config(self, cfg: Dict[Any, Any]) -> None:
135        if cfg is None:
136            cfg = {}
137        self._config.update(cfg)
138
139    def reset_config(self) -> None:
140        self._config: Dict[Any, Any] = {}
141        self._update_config(self.default_config)
142
143    def _load_config_from_string(  # pylint: disable=no-self-use
144        self, file_contents: str
145    ) -> List[Dict[Any, Any]]:
146        return list(yaml.safe_load_all(file_contents))
147
148    def load_config_file(self, file_path: Path) -> None:
149        if not file_path.is_file():
150            return
151
152        cfgs = self._load_config_from_string(file_path.read_text())
153
154        for cfg in cfgs:
155            if self._config_section_title in cfg:
156                self._update_config(cfg[self._config_section_title])
157
158            elif cfg.get('config_title', False) == self._config_section_title:
159                self._update_config(cfg)
160            else:
161                raise MissingConfigTitle(
162                    '\n\nThe config file "{}" is missing the expected '
163                    '"config_title: {}" setting.'.format(
164                        str(file_path), self._config_section_title
165                    )
166                )
167