• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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