xref: /aosp_15_r20/external/pigweed/pw_build/py/pw_build/project_builder_prefs.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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