1# Copyright 2025 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"""A library that bridges runfiles management between Bazel and bootstrap.""" 15 16import importlib 17import os 18from pathlib import Path 19import re 20import shutil 21import subprocess 22from typing import List 23 24from pw_cli.tool_runner import ToolRunner 25 26try: 27 from python.runfiles import runfiles # type: ignore 28 29 _IS_BOOTSTRAP = False 30except ImportError: 31 _IS_BOOTSTRAP = True 32 33_ENV_VAR_PATTERN = re.compile(r'\${([^}]*)}') 34 35 36class RunfilesManager(ToolRunner): 37 """A class that helps manage runtime file resources. 38 39 Projects that use bootstrap get their files from the currently 40 activated environment. In contrast, Bazel-based projects will get their 41 files from Bazel's runfiles mechanism. 42 43 This class bridges those differences, and simplifies the process of 44 writing tools that work in both environments. Every resource is associated 45 with a key used to retrieve the resource, and the resources must be 46 registered twice: once for Bazel-based projects, and once for other 47 projects that run from a shell environment bootstrapped by pw_env_setup. 48 To ensure consistency, if a resource is used by one environment, an error 49 will be emitted if it was never registered for the other environment. 50 51 If a file is exclusive to one of the two environments, it may be tagged 52 as ``exclusive=True`` to suppress the error emitted if a resource isn't 53 properly registered for the other environment. Attempting to access a 54 resource ``exclusive`` to a different environment will still raise an error. 55 56 This class is also a :py:class:`pw_cli.tool_runner.ToolRunner`, and may be 57 used to launch subprocess actions. 58 """ 59 60 def __init__(self): 61 self._runfiles: dict[str, Path] = {} 62 self._tools: dict[str, str] = {} 63 self._bazel_resources: set[str] = set() 64 self._bootstrap_resources: set[str] = set() 65 if _IS_BOOTSTRAP: 66 self._r = None 67 else: 68 self._r = runfiles.Create() 69 70 def add_bazel_tool( 71 self, tool_name: str, import_path: str, exclusive: bool = False 72 ) -> None: 73 """Maps a runnable tool to the provided tool name. 74 75 Files added through this mechanism will be available when running from 76 the Bazel build. Unless you specify ``exclusive=True``, you must also 77 register this file with 78 :py:meth:`pw_build.runfiles_manager.RunfilesManager.add_bootstrapped_tool()` 79 before attempting to use it. 80 81 The ``import_path`` is the import path of the 82 ``pw_py_importable_runfile`` rule that provides the desired file. 83 """ # pylint: disable=line-too-long 84 self._map_bazel_runfile( 85 tool_name, import_path, executable=True, exclusive=exclusive 86 ) 87 88 def add_bootstrapped_tool( 89 self, 90 tool_name: str, 91 path_or_env: str, 92 from_shell_path: bool = False, 93 exclusive: bool = False, 94 ) -> None: 95 """Maps a runnable tool to the provided tool name. 96 97 Files added through this mechanism will be available from an 98 activated environment constructed via pw_env_setup. Unless you specify 99 ``exclusive=True``, you must also register this tool with 100 :py:meth:`pw_build.runfiles_manager.RunfilesManager.add_bazel_tool()` 101 before attempting to use it. 102 103 Environment variables may be expanded using ``${PW_FOO}`` within the 104 path expression. If ``from_shell_path=True`` is enabled, the active 105 shell ``PATH`` is searched for the requested tool, and environment 106 variables will **not** be expanded. 107 """ 108 self._map_bootstrap_runfile( 109 tool_name, 110 path_or_env, 111 executable=True, 112 from_shell_path=from_shell_path, 113 exclusive=exclusive, 114 ) 115 116 def add_bazel_file( 117 self, key: str, import_path: str, exclusive: bool = False 118 ) -> None: 119 """Maps a non-executable file resource to the provided key. 120 121 Files added through this mechanism will be available when running from 122 the Bazel build. Unless you specify ``exclusive=True``, you must also 123 register this file with 124 :py:meth:`pw_build.runfiles_manager.RunfilesManager.add_bootstrapped_file()` 125 before attempting to use it. 126 127 The ``import_path`` is the import path of the 128 ``pw_py_importable_runfile`` rule that provides the desired file. 129 """ # pylint: disable=line-too-long 130 self._map_bazel_runfile( 131 key, import_path, executable=False, exclusive=exclusive 132 ) 133 134 def add_bootstrapped_file( 135 self, key: str, path_or_env: str, exclusive: bool = False 136 ) -> None: 137 """Maps a non-executable file resource to the provided key. 138 139 Files added through this mechanism will be available from an activated 140 environment constructed via pw_env_setup. Unless you specify 141 ``exclusive=True``, you must also register this file with 142 :py:meth:`pw_build.runfiles_manager.RunfilesManager.add_bazel_file()` 143 before attempting to use it. 144 145 Environment variables may be expanded using ``${PW_FOO}`` within the 146 path expression. 147 """ 148 self._map_bootstrap_runfile( 149 key, path_or_env, executable=False, exclusive=exclusive 150 ) 151 152 def _map_bazel_runfile( 153 self, key: str, import_path: str, executable: bool, exclusive: bool 154 ) -> None: 155 if _IS_BOOTSTRAP: 156 self._register_bazel_resource(key, exclusive) 157 return 158 try: 159 module = importlib.import_module(import_path) 160 except ImportError: 161 raise ValueError( 162 f'Failed to load runfiles import `{key}={import_path}`. Did ' 163 'you forget to add a dependency on the appropriate ' 164 'pw_py_importable_runfile?' 165 ) 166 file_path = self._r.Rlocation(*module.RLOCATION) 167 self._check_path(file_path, key) 168 self._runfiles[key] = Path(file_path) 169 if executable: 170 self._tools[key] = file_path 171 self._register_bazel_resource(key, exclusive) 172 173 def _map_bootstrap_runfile( 174 self, 175 key: str, 176 path: str, 177 executable: bool, 178 exclusive: bool, 179 from_shell_path: bool = False, 180 ) -> None: 181 if not _IS_BOOTSTRAP: 182 self._register_bootstrap_resource(key, exclusive) 183 return 184 if from_shell_path: 185 actual_path = shutil.which(path) 186 if actual_path is None: 187 raise ValueError(f'Tool `{key}={path}` not found on PATH') 188 path = actual_path 189 unknown_vars: List[str] = [] 190 known_vars: dict[str, str] = {} 191 for fmt_var in _ENV_VAR_PATTERN.findall(path): 192 if not fmt_var: 193 raise ValueError( 194 f'Runfiles entry `{key}={path}` has a format expansion ' 195 'with no environment variable name' 196 ) 197 if fmt_var not in os.environ: 198 unknown_vars.append(fmt_var) 199 else: 200 if os.environ[fmt_var] is None: 201 raise ValueError( 202 f'Runfiles entry `{key}={path}` requested the ' 203 '{fmt_var} environment variable, which is set but empty' 204 ) 205 known_vars[fmt_var] = os.environ[fmt_var] 206 if unknown_vars: 207 raise ValueError( 208 'Failed to expand the following environment variables for ' 209 f'runfile entry `{key}={path}`: {", ".join(unknown_vars)}' 210 ) 211 file_path = _ENV_VAR_PATTERN.sub( 212 lambda m: known_vars[m.group(1)], 213 path, 214 ) 215 self._check_path(file_path, key) 216 217 self._runfiles[key] = Path(file_path) 218 if executable: 219 self._tools[key] = file_path 220 self._register_bootstrap_resource(key, exclusive) 221 222 def _register_bootstrap_resource(self, key: str, exclusive: bool): 223 self._bootstrap_resources.add(key) 224 if exclusive: 225 self._bazel_resources.add(key) 226 227 def _register_bazel_resource(self, key: str, exclusive: bool): 228 self._bazel_resources.add(key) 229 if exclusive: 230 self._bootstrap_resources.add(key) 231 232 @staticmethod 233 def _check_path(path: str, key: str): 234 if not Path(path).is_file(): 235 raise ValueError(f'Runfile `{key}={path}` does not exist') 236 237 def get(self, key: str) -> Path: 238 """Retrieves the ``Path`` to the resource at the requested key.""" 239 not_known_by = [] 240 if not key in self._bazel_resources: 241 not_known_by.append('Bazel') 242 if not key in self._bootstrap_resources: 243 not_known_by.append('bootstrap') 244 if not_known_by: 245 if len(not_known_by) == 1: 246 which = not_known_by[0] 247 other = lambda e: 'Bazel' if e == 'bootstrap' else 'bootstrap' 248 raise ValueError( 249 f'`{key}` was registered for {other(which)} environments, ' 250 f'but not for {which} environments. Either register in ' 251 f'{which} or mark as `exclusive=True`' 252 ) 253 raise ValueError( 254 f'`{key}` is not a registered tool or runfile resource' 255 ) 256 if not key in self._runfiles: 257 this_environment_kind = 'bootstrap' if _IS_BOOTSTRAP else 'Bazel' 258 other_environment_kind = 'Bazel' if _IS_BOOTSTRAP else 'bootstrap' 259 raise ValueError( 260 f'`{key}` was marked as `exclusive=True` to ' 261 f'{other_environment_kind} environments, but was used ' 262 f'in a {this_environment_kind} environment' 263 ) 264 265 # Note that this is intentionally designed not to return `None`, as 266 # that makes it too easy to mask issues due to missing resources. Often, 267 # this causes bugs to occur later during execution of a script in ways 268 # that are less clearly spelled out. 269 return self._runfiles[key] 270 271 def __getitem__(self, key): 272 return self.get(key) 273 274 def _run_tool( 275 self, tool: str, args, **kwargs 276 ) -> subprocess.CompletedProcess: 277 tool_path = self.get(tool) 278 if tool not in self._tools: 279 raise ValueError( 280 f'`{tool}` was registered as a file rather than a runnable ' 281 'tool. Register it with add_bazel_tool() and/or ' 282 'add_bootstrapped_tool() to make it runnable.' 283 ) 284 return subprocess.run([str(tool_path), *args], **kwargs) 285