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