blob: 978f3a975bb0f32b31343f3a6098ddefdb058d97 [file] [log] [blame]
"""Utility functions to manipulate Bazel files"""
import os
import textwrap
import json
from typing import Iterable, List, Dict, Set
from python.pip_install.extract_wheels.lib import namespace_pkgs, wheel, purelib
def generate_build_file_contents(
name: str, dependencies: List[str], pip_data_exclude: List[str]
) -> str:
"""Generate a BUILD file for an unzipped Wheel
Args:
name: the target name of the py_library
dependencies: a list of Bazel labels pointing to dependencies of the library
Returns:
A complete BUILD file as a string
We allow for empty Python sources as for Wheels containing only compiled C code
there may be no Python sources whatsoever (e.g. packages written in Cython: like `pymssql`).
"""
data_exclude = ["**/*.py", "**/* *", "BUILD", "WORKSPACE"] + pip_data_exclude
return textwrap.dedent(
"""\
package(default_visibility = ["//visibility:public"])
load("@rules_python//python:defs.bzl", "py_library")
py_library(
name = "{name}",
srcs = glob(["**/*.py"], allow_empty = True),
data = glob(["**/*"], exclude={data_exclude}),
# This makes this directory a top-level in the python import
# search path for anything that depends on this.
imports = ["."],
deps = [{dependencies}],
)
""".format(
name=name,
dependencies=",".join(dependencies),
data_exclude=json.dumps(data_exclude),
)
)
def generate_requirements_file_contents(repo_name: str, targets: Iterable[str]) -> str:
"""Generate a requirements.bzl file for a given pip repository
The file allows converting the PyPI name to a bazel label. Additionally, it adds a function which can glob all the
installed dependencies. This is provided for legacy reasons and can be considered deprecated.
Args:
repo_name: the name of the pip repository
targets: a list of Bazel labels pointing to all the generated targets
Returns:
A complete requirements.bzl file as a string
"""
return textwrap.dedent(
"""\
# Deprecated. This will be removed in a future release
all_requirements = [{requirement_labels}]
def requirement(name):
name_key = name.replace("-", "_").replace(".", "_").lower()
return "{repo}//pypi__" + name_key
""".format(
repo=repo_name, requirement_labels=",".join(sorted(targets))
)
)
def sanitise_name(name: str) -> str:
"""Sanitises the name to be compatible with Bazel labels.
There are certain requirements around Bazel labels that we need to consider. From the Bazel docs:
Package names must be composed entirely of characters drawn from the set A-Z, a–z, 0–9, '/', '-', '.', and '_',
and cannot start with a slash.
Due to restrictions on Bazel labels we also cannot allow hyphens. See
https://github.com/bazelbuild/bazel/issues/6841
Further, rules-python automatically adds the repository root to the PYTHONPATH, meaning a package that has the same
name as a module is picked up. We workaround this by prefixing with `pypi__`. Alternatively we could require
`--noexperimental_python_import_all_repositories` be set, however this breaks rules_docker.
See: https://github.com/bazelbuild/bazel/issues/2636
"""
return "pypi__" + name.replace("-", "_").replace(".", "_").lower()
def setup_namespace_pkg_compatibility(wheel_dir: str) -> None:
"""Converts native namespace packages to pkgutil-style packages
Namespace packages can be created in one of three ways. They are detailed here:
https://packaging.python.org/guides/packaging-namespace-packages/#creating-a-namespace-package
'pkgutil-style namespace packages' (2) and 'pkg_resources-style namespace packages' (3) works in Bazel, but
'native namespace packages' (1) do not.
We ensure compatibility with Bazel of method 1 by converting them into method 2.
Args:
wheel_dir: the directory of the wheel to convert
"""
namespace_pkg_dirs = namespace_pkgs.implicit_namespace_packages(
wheel_dir, ignored_dirnames=["%s/bin" % wheel_dir,],
)
for ns_pkg_dir in namespace_pkg_dirs:
namespace_pkgs.add_pkgutil_style_namespace_pkg_init(ns_pkg_dir)
def extract_wheel(
wheel_file: str,
extras: Dict[str, Set[str]],
pip_data_exclude: List[str],
enable_implicit_namespace_pkgs: bool,
) -> str:
"""Extracts wheel into given directory and creates a py_library target.
Args:
wheel_file: the filepath of the .whl
extras: a list of extras to add as dependencies for the installed wheel
pip_data_exclude: list of file patterns to exclude from the generated data section of the py_library
enable_implicit_namespace_pkgs: if true, disables conversion of implicit namespace packages and will unzip as-is
Returns:
The Bazel label for the extracted wheel, in the form '//path/to/wheel'.
"""
whl = wheel.Wheel(wheel_file)
directory = sanitise_name(whl.name)
os.mkdir(directory)
whl.unzip(directory)
# Note: Order of operations matters here
purelib.spread_purelib_into_root(directory)
if not enable_implicit_namespace_pkgs:
setup_namespace_pkg_compatibility(directory)
extras_requested = extras[whl.name] if whl.name in extras else set()
sanitised_dependencies = [
'"//%s"' % sanitise_name(d) for d in sorted(whl.dependencies(extras_requested))
]
with open(os.path.join(directory, "BUILD"), "w") as build_file:
contents = generate_build_file_contents(
sanitise_name(whl.name), sanitised_dependencies, pip_data_exclude,
)
build_file.write(contents)
os.remove(whl.path)
return "//%s" % directory