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"""Pigweed Watch config file preferences loader.""" 15 16import argparse 17from pathlib import Path 18import shlex 19import shutil 20from typing import Any, Callable 21 22from pw_cli.env import running_under_bazel 23from pw_config_loader import yaml_config_loader_mixin 24 25DEFAULT_BUILD_DIRECTORY = 'out' 26 27 28def default_config(forced_build_system: str | None = None) -> dict[Any, Any]: 29 """Return either a ninja or bazel default build config. 30 31 These are the build configs used if a ProjectBuilder instance is provided no 32 build recipes. That is the same scenario as when no build related command 33 line flags are passed to pw build or pw watch (defined in 34 project_builder_argparse.py) 35 36 Args: 37 forced_build_system: If set to 'bazel' or 'ninja' return the assumed build 38 config for bazel. 39 40 Returns: A pw_config_loader dict representing the base build config. If 41 executed outside of bazel, the ninja config is returned. If executed 42 within a bazel run, the bazel config is returned. 43 """ 44 45 # Base config assuming ninja -C out 46 ninja_config: dict[Any, Any] = { 47 # Config settings not available as a command line options go here. 48 'build_system_commands': { 49 'default': { 50 'commands': [ 51 { 52 'command': 'ninja', 53 'extra_args': [], 54 }, 55 ], 56 }, 57 }, 58 } 59 60 bazel_command = 'bazel' 61 # Prefer bazelisk if available. 62 if shutil.which('bazelisk'): 63 bazel_command = 'bazelisk' 64 65 bazel_config: dict[Any, Any] = { 66 # Config settings not available as a command line options go here. 67 'build_system_commands': { 68 'default': { 69 'commands': [ 70 { 71 'command': bazel_command, 72 'extra_args': ['build'], 73 }, 74 { 75 'command': bazel_command, 76 'extra_args': ['test'], 77 }, 78 ], 79 }, 80 }, 81 # Bazel requires at least one target to build or test. 82 'default_build_targets': ['//...'], 83 } 84 85 if forced_build_system: 86 if forced_build_system == 'ninja': 87 return ninja_config 88 if forced_build_system == 'bazel': 89 return bazel_config 90 91 if running_under_bazel(): 92 return bazel_config 93 94 return ninja_config 95 96 97_DEFAULT_PROJECT_FILE = Path('$PW_PROJECT_ROOT/.pw_build.yaml') 98_DEFAULT_PROJECT_USER_FILE = Path('$PW_PROJECT_ROOT/.pw_build.user.yaml') 99_DEFAULT_USER_FILE = Path('$HOME/.pw_build.yaml') 100 101 102def load_defaults_from_argparse( 103 add_parser_arguments: Callable[ 104 [argparse.ArgumentParser], argparse.ArgumentParser 105 ] 106) -> dict[Any, Any]: 107 parser = argparse.ArgumentParser( 108 description='', formatter_class=argparse.RawDescriptionHelpFormatter 109 ) 110 parser = add_parser_arguments(parser) 111 default_namespace, _unknown_args = parser.parse_known_args( 112 [], # Pass in blank arguments to avoid catching args from sys.argv. 113 ) 114 defaults_flags = vars(default_namespace) 115 return defaults_flags 116 117 118class ProjectBuilderPrefs(yaml_config_loader_mixin.YamlConfigLoaderMixin): 119 """Pigweed Watch preferences storage class.""" 120 121 def __init__( 122 self, 123 load_argparse_arguments: Callable[ 124 [argparse.ArgumentParser], argparse.ArgumentParser 125 ], 126 project_file: Path | bool = _DEFAULT_PROJECT_FILE, 127 project_user_file: Path | bool = _DEFAULT_PROJECT_USER_FILE, 128 user_file: Path | bool = _DEFAULT_USER_FILE, 129 ) -> None: 130 self.load_argparse_arguments = load_argparse_arguments 131 132 self.config_init( 133 config_section_title='pw_build', 134 project_file=project_file, 135 project_user_file=project_user_file, 136 user_file=user_file, 137 default_config={}, 138 environment_var='PW_BUILD_CONFIG_FILE', 139 ) 140 141 def reset_config(self) -> None: 142 # Erase self._config and set to self.default_config. 143 super().reset_config() 144 # Get the config defined by argparse defaults. 145 argparse_config = load_defaults_from_argparse( 146 self.load_argparse_arguments 147 ) 148 self._update_config( 149 argparse_config, 150 yaml_config_loader_mixin.Stage.DEFAULT, 151 ) 152 153 def _argparse_build_system_commands_to_prefs( # pylint: disable=no-self-use 154 self, argparse_input: list[list[str]] 155 ) -> dict[str, Any]: 156 result: dict[str, Any] = {} 157 for out_dir, command in argparse_input: 158 new_dir_spec = result.get(out_dir, {}) 159 # Get existing commands list 160 new_commands = new_dir_spec.get('commands', []) 161 162 # Convert 'ninja -k 1' to 'ninja' and ['-k', '1'] 163 extra_args = [] 164 command_tokens = shlex.split(command) 165 if len(command_tokens) > 1: 166 extra_args = command_tokens[1:] 167 command = command_tokens[0] 168 169 # Append the command step 170 new_commands.append({'command': command, 'extra_args': extra_args}) 171 new_dir_spec['commands'] = new_commands 172 result[out_dir] = new_dir_spec 173 return result 174 175 def apply_command_line_args(self, new_args: argparse.Namespace) -> None: 176 """Update the stored config with an argparse namespace.""" 177 default_args = load_defaults_from_argparse(self.load_argparse_arguments) 178 179 # Only apply settings that differ from the defaults. 180 changed_settings: dict[Any, Any] = {} 181 for key, value in vars(new_args).items(): 182 if key in default_args and value != default_args[key]: 183 if key == 'build_system_commands': 184 value = self._argparse_build_system_commands_to_prefs(value) 185 changed_settings[key] = value 186 187 # Apply the default build configs if no build directories and build 188 # systems were supplied on the command line. 189 fallback_build_config = default_config( 190 forced_build_system=changed_settings.get( 191 'default_build_system', None 192 ) 193 ) 194 195 # If no --build-system-commands provided on the command line, set them 196 # via the fallback (ninja or bazel). 197 if 'build_system_commands' not in changed_settings: 198 changed_settings['build_system_commands'] = fallback_build_config[ 199 'build_system_commands' 200 ] 201 202 # If the user did not specify a default build system command: 203 if 'default' not in changed_settings['build_system_commands']: 204 # Check if there are any build directories with no matching build 205 # system commands. 206 for build_dir, targets in changed_settings.get( 207 'build_directories', [] 208 ): 209 if build_dir not in changed_settings['build_system_commands']: 210 # Build dir has no defined build system command. Set the 211 # fallback default. 212 changed_settings['build_system_commands'][ 213 'default' 214 ] = fallback_build_config['build_system_commands'][ 215 'default' 216 ] 217 218 # If no targets are on the command line, set them via the fallback. 219 if ( 220 # Targets without a build directory: 221 # 'pw watch docs python.lint' 222 'default_build_targets' not in changed_settings 223 # Targets with a build directory: 224 # 'pw watch -C outdir docs python.lint' 225 and 'build_directories' not in changed_settings 226 ): 227 targets = fallback_build_config.get('default_build_targets', None) 228 if targets: 229 changed_settings['default_build_targets'] = targets 230 231 # Apply the changed settings. 232 self._update_config( 233 changed_settings, 234 yaml_config_loader_mixin.Stage.DEFAULT, 235 ) 236 237 @property 238 def run_commands(self) -> list[str]: 239 return self._config.get('run_command', []) 240 241 @property 242 def build_directories(self) -> dict[str, list[str]]: 243 """Returns build directories and the targets to build in each.""" 244 build_directories: list[str] | dict[str, list[str]] = self._config.get( 245 'build_directories', {} 246 ) 247 final_build_dirs: dict[str, list[str]] = {} 248 249 if isinstance(build_directories, dict): 250 final_build_dirs = build_directories 251 else: 252 # Convert list style command line arg to dict 253 for build_dir in build_directories: 254 # build_dir should be a list of strings from argparse 255 assert isinstance(build_dir, list) 256 assert isinstance(build_dir[0], str) 257 build_dir_name = build_dir[0] 258 new_targets = build_dir[1:] 259 # Append new targets in case out dirs are repeated on the 260 # command line. For example: 261 # -C out python.tests -C out python.lint 262 existing_targets = final_build_dirs.get(build_dir_name, []) 263 existing_targets.extend(new_targets) 264 final_build_dirs[build_dir_name] = existing_targets 265 266 # If no build directory was specified fall back to 'out' with 267 # default_build_targets or empty targets. If run_commands were supplied, 268 # only run those by returning an empty final_build_dirs list. 269 if not final_build_dirs and not self.run_commands: 270 final_build_dirs[DEFAULT_BUILD_DIRECTORY] = self._config.get( 271 'default_build_targets', [] 272 ) 273 274 return final_build_dirs 275 276 def _get_build_system_commands_for(self, build_dir: str) -> dict[str, Any]: 277 config_dict = self._config.get('build_system_commands', {}) 278 if not config_dict: 279 config_dict = default_config()['build_system_commands'] 280 default_system_commands: dict[str, Any] = config_dict.get('default', {}) 281 if default_system_commands is None: 282 default_system_commands = {} 283 build_system_commands = config_dict.get(build_dir) 284 285 # In case 'out:' is in the config but has no contents. 286 if not build_system_commands: 287 return default_system_commands 288 289 return build_system_commands 290 291 def build_system_commands( 292 self, build_dir: str 293 ) -> list[tuple[str, list[str]]]: 294 build_system_commands = self._get_build_system_commands_for(build_dir) 295 296 command_steps: list[tuple[str, list[str]]] = [] 297 commands: list[dict[str, Any]] = build_system_commands.get( 298 'commands', [] 299 ) 300 for command_step in commands: 301 command_steps.append( 302 (command_step['command'], command_step['extra_args']) 303 ) 304 return command_steps 305