forked from bazel-contrib/rules_python
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathlocal_runtime_repo.bzl
More file actions
383 lines (332 loc) · 14.1 KB
/
local_runtime_repo.bzl
File metadata and controls
383 lines (332 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# Copyright 2024 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Create a repository for a locally installed Python runtime."""
load(":enum.bzl", "enum")
load(":repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "repo_utils")
# buildifier: disable=name-conventions
_OnFailure = enum(
SKIP = "skip",
WARN = "warn",
FAIL = "fail",
)
_TOOLCHAIN_IMPL_TEMPLATE = """\
# Generated by python/private/local_runtime_repo.bzl
load("@rules_python//python/private:local_runtime_repo_setup.bzl", "define_local_runtime_toolchain_impl")
define_local_runtime_toolchain_impl(
name = "local_runtime",
major = "{major}",
minor = "{minor}",
micro = "{micro}",
abi_flags = "{abi_flags}",
os = "{os}",
implementation_name = "{implementation_name}",
interpreter_path = "{interpreter_path}",
interface_library = {interface_library},
libraries = {libraries},
defines = {defines},
abi3_interface_library = {abi3_interface_library},
abi3_libraries = {abi3_libraries},
additional_dlls = {additional_dlls},
)
"""
def _expand_incompatible_template():
return _TOOLCHAIN_IMPL_TEMPLATE.format(
major = "0",
minor = "0",
micro = "0",
abi_flags = "",
os = "@platforms//:incompatible",
implementation_name = "incompatible",
interpreter_path = "/incompatible",
interface_library = "None",
libraries = "[]",
defines = "[]",
abi3_interface_library = "None",
abi3_libraries = "[]",
additional_dlls = "[]",
)
def _norm_path(path):
"""Returns a path using '/' separators and no trailing slash."""
path = path.replace("\\", "/")
if path[-1] == "/":
path = path[:-1]
return path
def _symlink_libraries(rctx, logger, libraries, shlib_suffix):
"""Symlinks the shared libraries into the lib/ directory.
Individual files are symlinked instead of the whole directory because
shared_lib_dirs contains multiple search paths for the shared libraries,
and the python files may be missing from any of those directories, and
any of those directories may include non-python runtime libraries,
as would be the case if LIBDIR were, for example, /usr/lib.
Args:
rctx: A repository_ctx object
logger: A repo_utils.logger object
libraries: paths to libraries to attempt to symlink.
shlib_suffix: Optional. Ensure that the generated symlinks end with this suffix.
Returns:
A list of library paths (under lib/) linked by the action.
"""
result = []
for source in libraries:
origin = rctx.path(source)
if not origin.exists:
# The reported names don't always exist; it depends on the particulars
# of the runtime installation.
continue
if shlib_suffix and not origin.basename.endswith(shlib_suffix):
target = "lib/{}{}".format(origin.basename, shlib_suffix)
else:
target = "lib/{}".format(origin.basename)
logger.debug(lambda: "Symlinking {} to {}".format(origin, target))
rctx.watch(origin)
rctx.symlink(origin, target)
result.append(target)
return result
def _local_runtime_repo_impl(rctx):
logger = repo_utils.logger(rctx)
on_failure = rctx.attr.on_failure
def _emit_log(msg):
if on_failure == "fail":
logger.fail(msg)
elif on_failure == "warn":
logger.warn(msg)
else:
logger.debug(msg)
result = _resolve_interpreter_path(rctx)
if not result.resolved_path:
_emit_log(lambda: "interpreter not found: {}".format(result.describe_failure()))
# else, on_failure must be skip
rctx.file("BUILD.bazel", _expand_incompatible_template())
return
else:
interpreter_path = result.resolved_path
logger.info(lambda: "resolved interpreter {} to {}".format(rctx.attr.interpreter_path, interpreter_path))
exec_result = repo_utils.execute_unchecked(
rctx,
op = "local_runtime_repo.GetPythonInfo({})".format(rctx.name),
arguments = [
interpreter_path,
rctx.path(rctx.attr._get_local_runtime_info),
],
quiet = True,
logger = logger,
)
if exec_result.return_code != 0:
_emit_log(lambda: "GetPythonInfo failed: {}".format(exec_result.describe_failure()))
# else, on_failure must be skip
rctx.file("BUILD.bazel", _expand_incompatible_template())
return
info = json.decode(exec_result.stdout)
logger.info(lambda: _format_get_info_result(info))
# We use base_executable because we want the path within a Python
# installation directory ("PYTHONHOME"). The problems with sys.executable
# are:
# * If we're in an activated venv, then we don't want the venv's
# `bin/python3` path to be used -- it isn't an actual Python installation.
# * If sys.executable is a wrapper (e.g. pyenv), then (1) it may not be
# located within an actual Python installation directory, and (2) it
# can interfer with Python recognizing when it's within a venv.
#
# In some cases, it may be a symlink (usually e.g. `python3->python3.12`),
# but we don't realpath() it to respect what it has decided is the
# appropriate path.
interpreter_path = info["base_executable"]
# NOTE: Keep in sync with recursive glob in define_local_runtime_toolchain_impl
include_path = rctx.path(info["include"])
# The reported include path may not exist, and watching a non-existant
# path is an error. Silently skip, since includes are only necessary
# if C extensions are built.
if include_path.exists and include_path.is_dir:
rctx.watch_tree(include_path)
else:
pass
# The cc_library.includes values have to be non-absolute paths, otherwise
# the toolchain will give an error. Work around this error by making them
# appear as part of this repo.
logger.debug(lambda: "Symlinking {} to include".format(include_path))
rctx.symlink(include_path, "include")
rctx.report_progress("Symlinking external Python shared libraries")
interface_library = None
if info["dynamic_libraries"]:
libraries = _symlink_libraries(rctx, logger, info["dynamic_libraries"][:1], info["shlib_suffix"])
symlinked = _symlink_libraries(rctx, logger, info["interface_libraries"][:1], None)
if symlinked:
interface_library = symlinked[0]
else:
libraries = _symlink_libraries(rctx, logger, info["static_libraries"], None)
if not libraries:
logger.info("No python libraries found.")
abi3_interface_library = None
if info["abi_dynamic_libraries"]:
abi3_libraries = _symlink_libraries(rctx, logger, info["abi_dynamic_libraries"][:1], info["shlib_suffix"])
symlinked = _symlink_libraries(rctx, logger, info["abi_interface_libraries"][:1], None)
if symlinked:
abi3_interface_library = symlinked[0]
else:
abi3_libraries = []
logger.info("No abi3 python libraries found.")
additional_dlls = _symlink_libraries(rctx, logger, info["additional_dlls"], None)
build_bazel = _TOOLCHAIN_IMPL_TEMPLATE.format(
major = info["major"],
minor = info["minor"],
micro = info["micro"],
abi_flags = info["abi_flags"],
os = "@platforms//os:{}".format(repo_utils.get_platforms_os_name(rctx)),
implementation_name = info["implementation_name"],
interpreter_path = _norm_path(interpreter_path),
interface_library = repr(interface_library),
libraries = repr(libraries),
defines = repr(info["defines"]),
abi3_interface_library = repr(abi3_interface_library),
abi3_libraries = repr(abi3_libraries),
additional_dlls = repr(additional_dlls),
)
logger.debug(lambda: "BUILD.bazel\n{}".format(build_bazel))
rctx.file("WORKSPACE", "")
rctx.file("MODULE.bazel", "")
rctx.file("REPO.bazel", "")
rctx.file("BUILD.bazel", build_bazel)
local_runtime_repo = repository_rule(
implementation = _local_runtime_repo_impl,
doc = """
Use a locally installed Python runtime as a toolchain implementation.
Note this uses the runtime as a *platform runtime*. A platform runtime means
means targets don't include the runtime itself as part of their runfiles or
inputs. Instead, users must assure that where the targets run have the runtime
pre-installed or otherwise available.
This results in lighter weight binaries (in particular, Bazel doesn't have to
create thousands of files for every `py_test`), at the risk of having to rely on
a system having the necessary Python installed.
""",
attrs = {
"interpreter_path": attr.string(
doc = """
An absolute path or program name on the `PATH` env var.
*Mutually exclusive with `interpreter_target`.*
Values with slashes are assumed to be the path to a program. Otherwise, it is
treated as something to search for on `PATH`
Note that, when a plain program name is used, the path to the interpreter is
resolved at repository evalution time, not runtime of any resulting binaries.
If not set, defaults to `python3`.
:::{seealso}
The {obj}`interpreter_target` attribute for getting the interpreter from
a label
:::
""",
default = "",
),
"interpreter_target": attr.label(
doc = """
A label to a Python interpreter executable.
*Mutually exclusive with `interpreter_path`.*
On Windows, if the path doesn't exist, various suffixes will be tried to
find a usable path.
:::{seealso}
The {obj}`interpreter_path` attribute for getting the interpreter from
a path or PATH environment lookup.
:::
""",
),
"on_failure": attr.string(
default = _OnFailure.SKIP,
values = sorted(_OnFailure.__members__.values()),
doc = """
How to handle errors when trying to automatically determine settings.
* `skip` will silently skip creating a runtime. Instead, a non-functional
runtime will be generated and marked as incompatible so it cannot be used.
This is best if a local runtime is known not to work or be available
in certain cases and that's OK. e.g., one use windows paths when there
are people running on linux.
* `warn` will print a warning message. This is useful when you expect
a runtime to be available, but are OK with it missing and falling back
to some other runtime.
* `fail` will result in a failure. This is only recommended if you must
ensure the runtime is available.
""",
),
"_get_local_runtime_info": attr.label(
allow_single_file = True,
default = "//python/private:get_local_runtime_info.py",
),
"_rule_name": attr.string(default = "local_runtime_repo"),
},
environ = ["PATH", REPO_DEBUG_ENV_VAR, "DEVELOPER_DIR", "XCODE_VERSION"],
)
def _find_python_exe_from_target(rctx):
base_path = rctx.path(rctx.attr.interpreter_target)
if base_path.exists:
return base_path, None
attempted_paths = [base_path]
# Try to convert a unix-y path to a Windows path. On Linux/Mac,
# the path is usually `bin/python3`. On Windows, it's simply
# `python.exe`.
basename = base_path.basename.rstrip("3")
path = base_path.dirname.dirname.get_child(basename)
path = rctx.path("{}.exe".format(path))
if path.exists:
return path, None
attempted_paths.append(path)
# Try adding .exe to the base path
path = rctx.path("{}.exe".format(base_path))
if path.exists:
return path, None
attempted_paths.append(path)
describe_failure = lambda: (
"Target '{target}' could not be resolved to a valid path. " +
"Attempted paths: {paths}"
).format(
target = rctx.attr.interpreter_target,
paths = "\n".join([str(p) for p in attempted_paths]),
)
return None, describe_failure
def _resolve_interpreter_path(rctx):
"""Find the absolute path for an interpreter.
Args:
rctx: A repository_ctx object
Returns:
`struct` with the following fields:
* `resolved_path`: `path` object of a path that exists
* `describe_failure`: `Callable | None`. If a path that doesn't exist,
returns a description of why it couldn't be resolved
A path object or None. The path may not exist.
"""
if rctx.attr.interpreter_path and rctx.attr.interpreter_target:
fail("interpreter_path and interpreter_target are mutually exclusive")
if rctx.attr.interpreter_target:
resolved_path, describe_failure = _find_python_exe_from_target(rctx)
else:
interpreter_path = rctx.attr.interpreter_path or "python3"
if "/" not in interpreter_path and "\\" not in interpreter_path:
# Provide a bit nicer integration with pyenv: recalculate the runtime if the
# user changes the python version using e.g. `pyenv shell`
rctx.getenv("PYENV_VERSION")
result = repo_utils.which_unchecked(rctx, interpreter_path)
resolved_path = result.binary
describe_failure = result.describe_failure
else:
rctx.watch(interpreter_path)
resolved_path = rctx.path(interpreter_path)
if not resolved_path.exists:
describe_failure = lambda: "Path not found: {}".format(repr(interpreter_path))
else:
describe_failure = None
return struct(
resolved_path = resolved_path,
describe_failure = describe_failure,
)
def _format_get_info_result(info):
lines = ["GetPythonInfo result:"]
for key, value in sorted(info.items()):
lines.append(" {}: {}".format(key, value if value != "" else "<empty string>"))
return "\n".join(lines)