blob: 1764abe70d90d1157584226f1199b7d65cea3085 [file]
""
load("//python/private:auth.bzl", "get_auth")
load("//python/private:envsubst.bzl", "envsubst")
load("//python/private:is_standalone_interpreter.bzl", "is_standalone_interpreter")
load("//python/private:repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "repo_utils")
load(":attrs.bzl", "ATTRS", "use_isolated")
load(":deps.bzl", "all_repo_names", "record_files")
load(":patch_and_extract_whl.bzl", "patch_and_extract_whl")
load(":pypi_repo_utils.bzl", "pypi_repo_utils")
load(":urllib.bzl", "urllib")
load(":whl_archive.bzl", "whl_archive_attrs")
_CPPFLAGS = "CPPFLAGS"
_COMMAND_LINE_TOOLS_PATH_SLUG = "commandlinetools"
def _get_xcode_location_cflags(rctx, logger = None):
"""Query the xcode sdk location to update cflags
Figure out if this interpreter target comes from rules_python, and patch the xcode sdk location if so.
Pip won't be able to compile c extensions from sdists with the pre built python distributions from astral-sh
otherwise. See https://github.com/astral-sh/python-build-standalone/issues/103
"""
# Only run on MacOS hosts
if not rctx.os.name.lower().startswith("mac os"):
return []
xcode_sdk_location = repo_utils.execute_unchecked(
rctx,
op = "GetXcodeLocation",
arguments = [repo_utils.which_checked(rctx, "xcode-select"), "--print-path"],
logger = logger,
)
if xcode_sdk_location.return_code != 0:
return []
xcode_root = xcode_sdk_location.stdout.strip()
if _COMMAND_LINE_TOOLS_PATH_SLUG not in xcode_root.lower():
# This is a full xcode installation somewhere like /Applications/Xcode13.0.app/Contents/Developer
# so we need to change the path to to the macos specific tools which are in a different relative
# path than xcode installed command line tools.
xcode_sdks_json = repo_utils.execute_checked(
rctx,
op = "LocateXCodeSDKs",
arguments = [
repo_utils.which_checked(rctx, "xcrun"),
"xcodebuild",
"-showsdks",
"-json",
],
environment = {
"DEVELOPER_DIR": xcode_root,
},
logger = logger,
).stdout
xcode_sdks = json.decode(xcode_sdks_json)
potential_sdks = [
sdk
for sdk in xcode_sdks
if "productName" in sdk and
sdk["productName"] == "macOS" and
"darwinos" not in sdk["canonicalName"]
]
# Now we'll get two entries here (one for internal and another one for public)
# It shouldn't matter which one we pick.
xcode_sdk_path = potential_sdks[0]["sdkPath"]
else:
xcode_sdk_path = "{}/SDKs/MacOSX.sdk".format(xcode_root)
return [
"-isysroot {}".format(xcode_sdk_path),
]
def _get_toolchain_unix_cflags(rctx, python_interpreter, logger = None):
"""Gather cflags from a standalone toolchain for unix systems.
Pip won't be able to compile c extensions from sdists with the pre built python distributions from astral-sh
otherwise. See https://github.com/astral-sh/python-build-standalone/issues/103
"""
# Only run on Unix systems
if not rctx.os.name.lower().startswith(("mac os", "linux")):
return []
# Only update the location when using a standalone toolchain.
if not is_standalone_interpreter(rctx, python_interpreter, logger = logger):
return []
stdout = pypi_repo_utils.execute_checked_stdout(
rctx,
op = "GetPythonVersionForUnixCflags",
# python_interpreter by default points to a symlink, however when using bazel in vendor mode,
# and the vendored directory moves around, the execution of python fails, as it's getting confused
# where it's running from. More to the fact that we are executing it in isolated mode "-I", which
# results in PYTHONHOME being ignored. The solution is to run python from it's real directory.
python = python_interpreter.realpath,
arguments = [
# Run the interpreter in isolated mode, this options implies -E, -P and -s.
# Ensures environment variables are ignored that are set in userspace, such as PYTHONPATH,
# which may interfere with this invocation.
"-I",
"-c",
"import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}', end='')",
],
srcs = [],
logger = logger,
)
_python_version = stdout
include_path = "{}/include/python{}".format(
python_interpreter.dirname,
_python_version,
)
return ["-isystem {}".format(include_path)]
def _parse_optional_attrs(rctx, args, extra_pip_args = None):
"""Helper function to parse common attributes of pip_repository and whl_library repository rules.
This function also serializes the structured arguments as JSON
so they can be passed on the command line to subprocesses.
Args:
rctx: Handle to the rule repository context.
args: A list of parsed args for the rule.
extra_pip_args: The pip args to pass.
Returns: Augmented args list.
"""
if use_isolated(rctx, rctx.attr):
args.append("--isolated")
# Check for None so we use empty default types from our attrs.
# Some args want to be list, and some want to be dict.
if extra_pip_args != None:
args += [
"--extra_pip_args",
json.encode(struct(arg = [
envsubst(pip_arg, rctx.attr.envsubst, rctx.getenv)
for pip_arg in extra_pip_args
])),
]
if rctx.attr.download_only:
args.append("--download_only")
if rctx.attr.pip_data_exclude != None:
args += [
"--pip_data_exclude",
json.encode(struct(arg = rctx.attr.pip_data_exclude)),
]
env = {}
if rctx.attr.environment != None:
for key, value in rctx.attr.environment.items():
env[key] = value
# This is super hacky, but working out something nice is tricky.
# This is in particular needed for psycopg2 which attempts to link libpython.a,
# in order to point the linker at the correct python intepreter.
if rctx.attr.add_libdir_to_library_search_path:
if "LDFLAGS" in env:
fail("Can't set both environment LDFLAGS and add_libdir_to_library_search_path")
command = [
pypi_repo_utils.resolve_python_interpreter(rctx),
"-c",
"import sys ; sys.stdout.write('{}/lib'.format(sys.exec_prefix))",
]
result = rctx.execute(command)
if result.return_code != 0:
fail("Failed to get LDFLAGS path: command: {}, exit code: {}, stdout: {}, stderr: {}".format(command, result.return_code, result.stdout, result.stderr))
libdir = result.stdout
env["LDFLAGS"] = "-L{}".format(libdir)
args += [
"--environment",
json.encode(struct(arg = env)),
]
return args
def _get_python_home(rctx, python_interpreter, logger = None):
"""Get the PYTHONHOME directory from the selected python interpretter
Args:
rctx (repository_ctx): The repository context.
python_interpreter (path): The resolved python interpreter.
logger: Optional logger to use for operations.
Returns:
String of PYTHONHOME directory.
"""
return pypi_repo_utils.execute_checked_stdout(
rctx,
op = "GetPythonHome",
# python_interpreter by default points to a symlink, however when using bazel in vendor mode,
# and the vendored directory moves around, the execution of python fails, as it's getting confused
# where it's running from. More to the fact that we are executing it in isolated mode "-I", which
# results in PYTHONHOME being ignored. The solution is to run python from it's real directory.
python = python_interpreter.realpath,
arguments = [
# Run the interpreter in isolated mode, this options implies -E, -P and -s.
# Ensures environment variables are ignored that are set in userspace, such as PYTHONPATH,
# which may interfere with this invocation.
"-I",
"-c",
"import sys; print(f'{sys.prefix}', end='')",
],
srcs = [],
logger = logger,
)
def _create_repository_execution_environment(rctx, python_interpreter, logger = None):
"""Create a environment dictionary for processes we spawn with rctx.execute.
Args:
rctx (repository_ctx): The repository context.
python_interpreter (path): The resolved python interpreter.
logger: Optional logger to use for operations.
Returns:
Dictionary of environment variable suitable to pass to rctx.execute.
"""
env = {
"PYTHONHOME": _get_python_home(rctx, python_interpreter, logger),
"PYTHONPATH": pypi_repo_utils.construct_pythonpath(
rctx,
entries = rctx.attr._python_path_entries,
),
}
# Gather any available CPPFLAGS values
#
# We may want to build in an environment without a cc toolchain.
# In those cases, we're limited to --download-only, but we should respect that here.
is_wheel = rctx.attr.filename and rctx.attr.filename.endswith(".whl")
if not (rctx.attr.download_only or is_wheel):
cppflags = []
cppflags.extend(_get_xcode_location_cflags(rctx, logger = logger))
cppflags.extend(_get_toolchain_unix_cflags(rctx, python_interpreter, logger = logger))
env[_CPPFLAGS] = " ".join(cppflags)
return env
def _pip_archive_impl(rctx):
logger = repo_utils.logger(rctx)
sdist_filename = None
extra_pip_args = []
extra_pip_args.extend(rctx.attr.extra_pip_args)
if rctx.attr.urls and rctx.attr.filename:
filename = rctx.attr.filename
urls = rctx.attr.urls
urls = [
urllib.absolute_url(
rctx.attr.index_url,
url,
envsubst = rctx.attr.envsubst,
getenv = rctx.getenv,
)
for url in urls
]
result = rctx.download(
url = urls,
output = filename,
sha256 = rctx.attr.sha256,
integrity = rctx.attr.integrity if not rctx.attr.sha256 else "",
auth = get_auth(rctx, urls),
)
if not rctx.attr.sha256 and not rctx.attr.integrity:
# this is only seen when there is a direct URL reference without a hash
logger.warn("Please update the requirement line to include the hash:\n{} \\\n --hash=sha256:{}".format(
rctx.attr.requirement,
result.sha256,
))
if not result.success:
fail("could not download the '{}' from {}:\n{}".format(filename, urls, result))
if filename.endswith(".whl"):
fail("Only sdists are supported")
else:
sdist_filename = filename
# It is an sdist and we need to tell PyPI to use a file in this directory
# and, allow getting build dependencies from PYTHONPATH, which we
# setup in this repository rule, but still download any necessary
# build deps from PyPI (e.g. `flit_core`) if they are missing.
extra_pip_args.extend(["--find-links", "."])
# When we already have a wheel, Python isn't used,
# so there's no need to setup env vars to run Python, unless we need to
# build an sdist or resolve a requirement.
python_interpreter = pypi_repo_utils.resolve_python_interpreter(
rctx,
python_interpreter = rctx.attr.python_interpreter,
python_interpreter_target = rctx.attr.python_interpreter_target,
)
args = [
"-m",
"python.private.pypi.whl_installer.wheel_installer",
"--requirement",
rctx.attr.requirement,
]
args = _parse_optional_attrs(rctx, args, extra_pip_args)
# Manually construct the PYTHONPATH since we cannot use the toolchain here
environment = _create_repository_execution_environment(rctx, python_interpreter, logger = logger)
if rctx.attr.urls:
op_tmpl = "whl_library.BuildWheelFromSource({name}, {requirement})"
elif rctx.attr.download_only:
op_tmpl = "whl_library.DownloadWheel({name}, {requirement})"
else:
op_tmpl = "whl_library.ResolveRequirement({name}, {requirement})"
pypi_repo_utils.execute_checked(
rctx,
# truncate the requirement value when logging it / reporting
# progress since it may contain several ' --hash=sha256:...
# --hash=sha256:...' substrings that fill up the console
python = python_interpreter,
op = op_tmpl.format(name = rctx.attr.name, requirement = rctx.attr.requirement.split(" ", 1)[0]),
arguments = args,
environment = environment,
srcs = rctx.attr._python_srcs,
quiet = rctx.attr.quiet,
timeout = rctx.attr.timeout,
logger = logger,
)
whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"])
if not rctx.delete("whl_file.json"):
fail("failed to delete the whl_file.json file")
# NOTE @aignas 2026-08-14: We never return rctx.metadata for pip archives because the result may
# not be reproducible across all machines given the input args to the repository rule.
patch_and_extract_whl(rctx, whl_path = whl_path, logger = logger, sdist_filename = sdist_filename)
# NOTE @aignas 2024-03-21: The usage of dict({}, **common) ensures that all args to `dict` are unique
pip_archive_attrs = {
k: v
for k, v in whl_archive_attrs.items()
# Only the whl_file parameter is unusable in the pip_archive rule. the rest can be
# reused.
if k != "whl_file"
} | {
k: ATTRS[k]
for k in [
# used for pulling deps with pip
"download_only",
"add_libdir_to_library_search_path",
"environment",
"extra_pip_args",
"isolated",
"python_interpreter",
"python_interpreter_target",
"quiet",
"timeout",
]
}
pip_archive = repository_rule(
attrs = pip_archive_attrs | {
"_python_path_entries": attr.label_list(
# Get the root directory of these rules and keep them as a default attribute
# in order to avoid unnecessary repository fetching restarts.
#
# This is very similar to what was done in https://github.com/bazelbuild/rules_go/pull/3478
default = [
Label("//:BUILD.bazel"),
] + [
# Includes all the external dependencies from repositories.bzl
Label("@" + repo + "//:BUILD.bazel")
for repo in all_repo_names
],
),
"_python_srcs": attr.label_list(
# Used as a default value in a rule to ensure we fetch the dependencies.
default = [
Label("//python/private/pypi/whl_installer:wheel_installer.py"),
Label("//python/private/pypi/whl_installer:arguments.py"),
] + record_files.values(),
),
"_rule_name": attr.string(default = "pip_archive"),
},
doc = """
Download and extracts a single wheel based into a bazel repo based on the requirement string passed in.
Instantiated from pip_repository and inherits config options from there.
:::{versionchanged} 1.9.0
The `whl_library` is marked as reproducible if using starlark to extract and parse the
wheel contents without building an `sdist` first.
:::
:::{versionchanged} 2.3.0
The whl-only pure Starlark operations have been refactored into {obj}`whl_archive` and the
previously named {obj}`whl_library` repository became renamed to `pip_archive`.
:::
""",
implementation = _pip_archive_impl,
environ = [
"RULES_PYTHON_PIP_ISOLATED",
REPO_DEBUG_ENV_VAR,
],
)