bazel/python_configure.bzl (369 lines of code) (raw):

"""Repository rule for Python autoconfiguration. `python_configure` depends on the following environment variables: * `PYTHON_BIN_PATH`: location of python binary. * `PYTHON_LIB_PATH`: Location of python libraries. """ _BAZEL_SH = "BAZEL_SH" _PYTHON_BIN_PATH = "/usr/bin/python3" _PYTHON_CONFIG_BIN_PATH = "PYTHON_CONFIG_BIN_PATH" _PYTHON_LIB_PATH = "/usr/lib64" def _tpl(repository_ctx, tpl, substitutions = {}, out = None): if not out: out = tpl repository_ctx.template( out, Label("//bazel/py:%s.tpl" % tpl), substitutions, ) def _fail(msg): """Output failure message when auto configuration fails.""" red = "\033[0;31m" no_color = "\033[0m" fail("%sPython Configuration Error:%s %s\n" % (red, no_color, msg)) def _is_windows(repository_ctx): """Returns true if the host operating system is windows.""" os_name = repository_ctx.os.name.lower() if os_name.find("windows") != -1: return True return False def _execute( repository_ctx, cmdline, error_msg = None, error_details = None, empty_stdout_fine = False): """Executes an arbitrary shell command. Args: repository_ctx: the repository_ctx object cmdline: list of strings, the command to execute error_msg: string, a summary of the error if the command fails error_details: string, details about the error or steps to fix it empty_stdout_fine: bool, if True, an empty stdout result is fine, otherwise it's an error Return: the result of repository_ctx.execute(cmdline) """ result = repository_ctx.execute(cmdline) if result.stderr or not (empty_stdout_fine or result.stdout): _fail("\n".join([ error_msg.strip() if error_msg else "Repository command failed", result.stderr.strip(), error_details if error_details else "", ])) return result def _read_dir(repository_ctx, src_dir): """Returns a string with all files in a directory. Finds all files inside a directory, traversing subfolders and following symlinks. The returned string contains the full path of all files separated by line breaks. """ if _is_windows(repository_ctx): src_dir = src_dir.replace("/", "\\") find_result = _execute( repository_ctx, ["cmd.exe", "/c", "dir", src_dir, "/b", "/s", "/a-d"], empty_stdout_fine = True, ) # src_files will be used in genrule.outs where the paths must # use forward slashes. result = find_result.stdout.replace("\\", "/") else: find_result = _execute( repository_ctx, ["find", src_dir, "-follow", "-type", "f"], empty_stdout_fine = True, ) result = find_result.stdout return result def _genrule(src_dir, genrule_name, command, outs, local): """Returns a string with a genrule. Genrule executes the given command and produces the given outputs. """ return ( "genrule(\n" + ' name = "' + genrule_name + '",\n' + ( " local = 1,\n" if local else "") + " outs = [\n" + outs + "\n ],\n" + ' cmd = """\n' + command + '\n """,\n' + ")\n" ) def _norm_path(path): """Returns a path with '/' and remove the trailing slash.""" path = path.replace("\\", "/") if path[-1] == "/": path = path[:-1] return path def _symlink_genrule_for_dir( repository_ctx, src_dir, dest_dir, genrule_name, src_files = [], dest_files = []): """Returns a genrule to symlink(or copy if on Windows) a set of files. If src_dir is passed, files will be read from the given directory; otherwise we assume files are in src_files and dest_files """ if src_dir != None: src_dir = _norm_path(src_dir) dest_dir = _norm_path(dest_dir) files = "\n".join(sorted(_read_dir(repository_ctx, src_dir).splitlines())) # Create a list with the src_dir stripped to use for outputs. dest_files = files.replace(src_dir, "").splitlines() src_files = files.splitlines() command = [] outs = [] for i in range(len(dest_files)): if dest_files[i] != "": # If we have only one file to link we do not want to use the dest_dir, as # $(@D) will include the full path to the file. dest = "$(@D)/" + dest_dir + dest_files[i] if len(dest_files) != 1 else "$(@D)/" + dest_files[i] # Copy the headers to create a sandboxable setup. cmd = "cp -f" command.append(cmd + ' "%s" "%s"' % (src_files[i], dest)) outs.append(' "' + dest_dir + dest_files[i] + '",') genrule = _genrule( src_dir, genrule_name, " && ".join(command), "\n".join(outs), local = True, ) return genrule def _get_python_bin(repository_ctx): """Gets the python bin path.""" if repository_ctx.attr.python_interpreter_target != None: return str(repository_ctx.path(repository_ctx.attr.python_interpreter_target)) python_bin = repository_ctx.os.environ.get(_PYTHON_BIN_PATH) if python_bin != None: return python_bin python_short_name = "python" + repository_ctx.attr.python_version python_bin_path = repository_ctx.which(python_short_name) if python_bin_path != None: return str(python_bin_path) _fail("Cannot find python in PATH, please make sure " + "python is installed and add its directory in PATH, or --define " + "%s='/something/else'.\nPATH=%s" % ( _PYTHON_BIN_PATH, repository_ctx.os.environ.get("PATH", ""), )) def _get_bash_bin(repository_ctx): """Gets the bash bin path.""" bash_bin = repository_ctx.os.environ.get(_BAZEL_SH) if bash_bin != None: return bash_bin else: bash_bin_path = repository_ctx.which("bash") if bash_bin_path != None: return str(bash_bin_path) else: _fail("Cannot find bash in PATH, please make sure " + "bash is installed and add its directory in PATH, or --define " + "%s='/path/to/bash'.\nPATH=%s" % ( _BAZEL_SH, repository_ctx.os.environ.get("PATH", ""), )) def _get_python_lib(repository_ctx, python_bin): """Gets the python lib path.""" python_lib = repository_ctx.os.environ.get(_PYTHON_LIB_PATH) if python_lib != None: return python_lib print_lib = ("<<END\n" + "from __future__ import print_function\n" + "import site\n" + "import os\n" + "\n" + "try:\n" + " input = raw_input\n" + "except NameError:\n" + " pass\n" + "\n" + "python_paths = []\n" + "if os.getenv('PYTHONPATH') is not None:\n" + " python_paths = os.getenv('PYTHONPATH').split(':')\n" + "try:\n" + " library_paths = site.getsitepackages()\n" + "except AttributeError:\n" + " from distutils.sysconfig import get_python_lib\n" + " library_paths = [get_python_lib()]\n" + "all_paths = set(python_paths + library_paths)\n" + "paths = []\n" + "for path in all_paths:\n" + " if os.path.isdir(path):\n" + " paths.append(path)\n" + "if len(paths) >=1:\n" + " print(paths[0])\n" + "END") cmd = "%s - %s" % (python_bin, print_lib) result = repository_ctx.execute([_get_bash_bin(repository_ctx), "-c", cmd]) return result.stdout.strip("\n") def _check_python_lib(repository_ctx, python_lib): """Checks the python lib path.""" cmd = 'test -d "%s" -a -x "%s"' % (python_lib, python_lib) result = repository_ctx.execute([_get_bash_bin(repository_ctx), "-c", cmd]) if result.return_code == 1: _fail("Invalid python library path: %s" % python_lib) def _check_python_bin(repository_ctx, python_bin): """Checks the python bin path.""" cmd = '[[ -x "%s" ]] && [[ ! -d "%s" ]]' % (python_bin, python_bin) result = repository_ctx.execute([_get_bash_bin(repository_ctx), "-c", cmd]) if result.return_code == 1: _fail("--define %s='%s' is not executable. Is it the python binary?" % ( _PYTHON_BIN_PATH, python_bin, )) def _get_python_include(repository_ctx, python_bin): """Gets the python include path.""" result = _execute( repository_ctx, [ python_bin, "-c", "from __future__ import print_function;" + "from distutils import sysconfig;" + "print(sysconfig.get_python_inc())", ], error_msg = "Problem getting python include path.", error_details = ("Is the Python binary path set up right? " + "(See ./configure or " + _PYTHON_BIN_PATH + ".) " + "Is distutils installed?"), ) return result.stdout.splitlines()[0] def _get_python_import_lib_name(repository_ctx, python_bin): """Get Python import library name (pythonXY.lib) on Windows.""" result = _execute( repository_ctx, [ python_bin, "-c", "import sys;" + 'print("python" + str(sys.version_info[0]) + ' + ' str(sys.version_info[1]) + ".lib")', ], error_msg = "Problem getting python import library.", error_details = ("Is the Python binary path set up right? " + "(See ./configure or " + _PYTHON_BIN_PATH + ".) "), ) return result.stdout.splitlines()[0] def _find_python_config(repository_ctx, python_bin): """Searches for python-config in the Python bin directory through the Python environment symlinks Returns a string path to python-config, or None if not found """ python_config = repository_ctx.os.environ.get(_PYTHON_CONFIG_BIN_PATH) if python_config != None: return python_config bin_dir = repository_ctx.path(python_bin).dirname bin_name = "python%s-config" % repository_ctx.attr.python_version for i in bin_dir.readdir(): if bin_name == i.basename: return str(bin_dir.get_child(bin_name)) symlink_base_dir = repository_ctx.path(python_bin).realpath.dirname for i in symlink_base_dir.readdir(): if bin_name == i.basename: return str(symlink_base_dir.get_child(bin_name)) return None def _get_embed_flags(repository_ctx, python_config): """Identifies compiler and linker flags output by python-config required to embed Python Returns a tuple containing the copts and linkopts, or empty strings if unsuccessful. """ err_cmd = python_config + " --help" comp_cmd = python_config + " --cflags" # --embed is an undocumented python >=3.8 flag. # See https://github.com/python/cpython/pull/13500 link_cmd = python_config + " --ldflags --embed" err = repository_ctx.execute([_get_bash_bin(repository_ctx), "-c", err_cmd]).stdout.strip("\n") compiler_flags = repository_ctx.execute([_get_bash_bin(repository_ctx), "-c", comp_cmd]).stdout.strip("\n") linker_flags = repository_ctx.execute([_get_bash_bin(repository_ctx), "-c", link_cmd]).stdout.strip("\n") if linker_flags == err: # Try again without --embed link_cmd = python_config + " --ldflags" linker_flags = repository_ctx.execute([_get_bash_bin(repository_ctx), "-c", link_cmd]).stdout.strip("\n") if linker_flags == err or compiler_flags == err: return "", "" return compiler_flags, linker_flags def _create_local_python_repository(repository_ctx): """Creates the repository containing files set up to build with Python.""" python_bin = _get_python_bin(repository_ctx) _check_python_bin(repository_ctx, python_bin) python_lib = _get_python_lib(repository_ctx, python_bin) _check_python_lib(repository_ctx, python_lib) python_include = _get_python_include(repository_ctx, python_bin) python_include_rule = _symlink_genrule_for_dir( repository_ctx, python_include, "python_include", "python_include", ) # To embed python in C++, we need the linker and compiler flags required to embed the Python interpreter # See https://docs.python.org/3/extending/embedding.html#embedding-python-in-c python_config = _find_python_config(repository_ctx, python_bin) python_embed_copts = "" python_embed_linkopts = "" if python_config: python_embed_copts, python_embed_linkopts = _get_embed_flags(repository_ctx, python_config) python_import_lib_genrule = "" # To build Python C/C++ extension on Windows, we need to link to python import library pythonXY.lib # See https://docs.python.org/3/extending/windows.html if _is_windows(repository_ctx): python_include = _norm_path(python_include) python_import_lib_name = _get_python_import_lib_name(repository_ctx, python_bin) python_import_lib_src = python_include.rsplit("/", 1)[0] + "/libs/" + python_import_lib_name python_import_lib_genrule = _symlink_genrule_for_dir( repository_ctx, None, "", "python_import_lib", [python_import_lib_src], [python_import_lib_name], ) _tpl(repository_ctx, "BUILD", { "%{PYTHON_BIN_PATH}": python_bin, "%{PYTHON_INCLUDE_GENRULE}": python_include_rule, "%{PYTHON_IMPORT_LIB_GENRULE}": python_import_lib_genrule, "%{PYTHON_EMBED_COPTS}": python_embed_copts, "%{PYTHON_EMBED_LINKOPTS}": python_embed_linkopts, }) def _create_remote_python_repository(repository_ctx, remote_config_repo): """Creates pointers to a remotely configured repo set up to build with Python. """ repository_ctx.template("BUILD", Label(remote_config_repo + ":BUILD"), {}) def _python_autoconf_impl(repository_ctx): """Implementation of the python_autoconf repository rule.""" if repository_ctx.attr.python_version not in ("2", "3", ""): _fail("Invalid python_version value, must be '2', '3' or '' (default).") _create_local_python_repository(repository_ctx) # Configure Activated Python Environment python_configure = repository_rule( implementation = _python_autoconf_impl, environ = [ _BAZEL_SH, _PYTHON_BIN_PATH, _PYTHON_LIB_PATH, ], attrs = { "python_version": attr.string(default=""), "python_interpreter_target": attr.label(), }, ) """Detects and configures the local Python. Add the following to your WORKSPACE FILE: ```python python_configure(name = "local_config_python") ``` Args: name: A unique name for this workspace rule. python_version: If set to "3", will build for Python 3, i.e. will build against the installation corresponding to the binary returned by `which python3`. If set to "2", will build for Python 2 (`which python2`). By default, will build for whatever Python version is returned by `which python`. python_interpreter_target: If set to a target providing a Python binary, will configure for that Python version instead of the installed binary. This configuration takes precedence over python_version. """