Skip to content

Commit

Permalink
Added setup_py_script macro to pip_parse and pip_install.
Browse files Browse the repository at this point in the history
  • Loading branch information
UebelAndre committed Nov 28, 2021
1 parent 2b1d6be commit 2063ce7
Show file tree
Hide file tree
Showing 12 changed files with 227 additions and 19 deletions.
28 changes: 28 additions & 0 deletions docs/pip.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,20 @@ alias(
)
```

Similar to the `entry_point` macro, `setup_py_script` allows accessing
[`setup.py` generated scripts][scripts]:

```python
load("@pip_deps//:requirements.bzl", "setup_py_script")

alias(
name = "flake8",
actual = setup_py_script("flake8"),
)
```

[scripts]: https://docs.python.org/3/distutils/setupscript.html#installing-scripts


**PARAMETERS**

Expand Down Expand Up @@ -209,6 +223,20 @@ alias(
)
```

Similar to the `entry_point` macro, `setup_py_script` allows accessing
[`setup.py` generated scripts][scripts]:

```python
load("@pip_deps//:requirements.bzl", "setup_py_script")

alias(
name = "flake8",
actual = setup_py_script("flake8"),
)
```

[scripts]: https://docs.python.org/3/distutils/setupscript.html#installing-scripts


**PARAMETERS**

Expand Down
8 changes: 8 additions & 0 deletions examples/pip_install/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ load(
"dist_info_requirement",
"entry_point",
"requirement",
"setup_py_script",
)
load("@rules_python//python:defs.bzl", "py_binary", "py_test")
load("@rules_python//python:pip.bzl", "compile_pip_requirements")
Expand Down Expand Up @@ -66,6 +67,11 @@ alias(
actual = entry_point("yamllint"),
)

alias(
name = "s3cmd",
actual = setup_py_script("s3cmd"),
)

# Check that our compiled requirements are up-to-date
compile_pip_requirements(
name = "requirements",
Expand All @@ -77,12 +83,14 @@ py_test(
name = "pip_install_test",
srcs = ["pip_install_test.py"],
data = [
":s3cmd",
":sphinx-build",
":yamllint",
data_requirement("s3cmd"),
dist_info_requirement("boto3"),
],
env = {
"S3CMD_SETUP_PY_SCRIPT": "$(rootpath :s3cmd)",
"SPHINX_BUILD_ENTRY_POINT": "$(rootpath :sphinx-build)",
"WHEEL_DATA_CONTENTS": "$(rootpaths {})".format(data_requirement("s3cmd")),
"WHEEL_DIST_INFO_CONTENTS": "$(rootpaths {})".format(dist_info_requirement("boto3")),
Expand Down
10 changes: 10 additions & 0 deletions examples/pip_install/pip_install_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ def test_entry_point_int_return(self):
subprocess.run([entry_point, "--option-does-not-exist"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.assertIn('returned non-zero exit status 2', str(context.exception))

def test_setup_py_script_s3cmd(self):
env = os.environ.get("S3CMD_SETUP_PY_SCRIPT")
self.assertIsNotNone(env)

script = Path(env)
self.assertTrue(script.exists())

proc = subprocess.run([script, "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.assertEqual(proc.stdout.decode("utf-8").strip(), "s3cmd version 2.1.0")

def test_data(self):
env = os.environ.get("WHEEL_DATA_CONTENTS")
self.assertIsNotNone(env)
Expand Down
8 changes: 8 additions & 0 deletions examples/pip_parse/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ load(
"dist_info_requirement",
"entry_point",
"requirement",
"setup_py_script",
)
load("@rules_python//python:defs.bzl", "py_binary", "py_test")
load("@rules_python//python:pip.bzl", "compile_pip_requirements")
Expand Down Expand Up @@ -64,6 +65,11 @@ alias(
actual = entry_point("yamllint"),
)

alias(
name = "s3cmd",
actual = setup_py_script("s3cmd"),
)

# This rule adds a convenient way to update the requirements file.
compile_pip_requirements(
name = "requirements",
Expand All @@ -77,12 +83,14 @@ py_test(
name = "pip_parse_test",
srcs = ["pip_parse_test.py"],
data = [
":s3cmd",
":sphinx-build",
":yamllint",
data_requirement("s3cmd"),
dist_info_requirement("requests"),
],
env = {
"S3CMD_SETUP_PY_SCRIPT": "$(rootpath :s3cmd)",
"SPHINX_BUILD_ENTRY_POINT": "$(rootpath :sphinx-build)",
"WHEEL_DATA_CONTENTS": "$(rootpaths {})".format(data_requirement("s3cmd")),
"WHEEL_DIST_INFO_CONTENTS": "$(rootpaths {})".format(dist_info_requirement("requests")),
Expand Down
10 changes: 10 additions & 0 deletions examples/pip_parse/pip_parse_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ def test_entry_point_int_return(self):
subprocess.run([entry_point, "--option-does-not-exist"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.assertIn('returned non-zero exit status 2', str(context.exception))

def test_setup_py_script_s3cmd(self):
env = os.environ.get("S3CMD_SETUP_PY_SCRIPT")
self.assertIsNotNone(env)

script = Path(env)
self.assertTrue(script.exists())

proc = subprocess.run([script, "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.assertEqual(proc.stdout.decode("utf-8").strip(), "s3cmd version 2.1.0")

def test_data(self):
env = os.environ.get("WHEEL_DATA_CONTENTS")
self.assertIsNotNone(env)
Expand Down
28 changes: 28 additions & 0 deletions python/pip.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ def pip_install(requirements, name = "pip", **kwargs):
)
```
Similar to the `entry_point` macro, `setup_py_script` allows accessing
[`setup.py` generated scripts][scripts]:
```python
load("@pip_deps//:requirements.bzl", "setup_py_script")
alias(
name = "flake8",
actual = setup_py_script("flake8"),
)
```
[scripts]: https://docs.python.org/3/distutils/setupscript.html#installing-scripts
Args:
requirements (Label): A 'requirements.txt' pip requirements file.
name (str, optional): A unique name for the created external repository (default 'pip').
Expand Down Expand Up @@ -165,6 +179,20 @@ def pip_parse(requirements_lock, name = "pip_parsed_deps", **kwargs):
)
```
Similar to the `entry_point` macro, `setup_py_script` allows accessing
[`setup.py` generated scripts][scripts]:
```python
load("@pip_deps//:requirements.bzl", "setup_py_script")
alias(
name = "flake8",
actual = setup_py_script("flake8"),
)
```
[scripts]: https://docs.python.org/3/distutils/setupscript.html#installing-scripts
Args:
requirements_lock (Label): A fully resolved 'requirements.txt' pip requirement file
containing the transitive set of your dependencies. If this file is passed instead
Expand Down
10 changes: 7 additions & 3 deletions python/pip_install/extract_wheels/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ def main() -> None:
# relative requirements to be correctly resolved. The --wheel-dir is therefore required to be repointed back to the
# current calling working directory (the repo root in .../external/name), where the wheel files should be written to
pip_args = (
[sys.executable, "-m", "pip"] +
(["--isolated"] if args.isolated else []) +
[sys.executable, "-m", "pip"] +
(["--isolated"] if args.isolated else []) +
["wheel", "-r", args.requirements] +
["--wheel-dir", os.getcwd()] +
deserialized_args["extra_pip_args"]
Expand All @@ -90,7 +90,11 @@ def main() -> None:
% (
repo_label,
bazel.extract_wheel(
whl, extras, deserialized_args["pip_data_exclude"], args.enable_implicit_namespace_pkgs
wheel_file=whl,
extras=extras,
pip_data_exclude=deserialized_args["pip_data_exclude"],
enable_implicit_namespace_pkgs=args.enable_implicit_namespace_pkgs,
repository_name=args.repo,
),
)
for whl in glob.glob("*.whl")
Expand Down
112 changes: 106 additions & 6 deletions python/pip_install/extract_wheels/lib/bazel.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Utility functions to manipulate Bazel files"""
import os
import textwrap
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Set
import json
from typing import Iterable, List, Dict, Set, Optional
import os
import shutil
from pathlib import Path
import textwrap

from python.pip_install.extract_wheels.lib import namespace_pkgs, wheel, purelib

Expand All @@ -14,6 +14,7 @@
DATA_LABEL = "data"
DIST_INFO_LABEL = "dist_info"
WHEEL_ENTRY_POINT_PREFIX = "rules_python_wheel_entry_point"
SETUP_PY_SCRIPT_PREFIX = "rules_python_setup_py_script"


def generate_entry_point_contents(entry_point: str, shebang: str = "#!/usr/bin/env python3") -> str:
Expand Down Expand Up @@ -76,6 +77,75 @@ def generate_entry_point_rule(script: str, pkg: str) -> str:
))


def generate_setup_py_script_contents(script: Path, repository_name: str, shebang: str = "#!/usr/bin/env python3") -> str:
"""Generate the contents of a `setup.py` generated script
For details on `setup.py` generated scripts, see:
https://docs.python.org/3/distutils/setupscript.html#installing-scripts
Args:
script (Path): The path to the `setup.py` script
repository_name (str): The name of the repository for the current wheel.
shebang (str, optional): The shebang to use for the generated python
file.
Returns:
str: A string of python code.
"""
script_path = Path("external") / repository_name / script
return textwrap.dedent("""\
{shebang}
import os
import sys
if __name__ == "__main__":
script = "{script}"
if not os.path.exists(script):
raise FileNotFoundError(script)
args = sys.argv.copy()
args[0] = script
os.execv(sys.executable, [sys.executable] + args)
""".format(
shebang=shebang,
script=str(script_path)
))


def generate_setup_py_script_rule(src: Path, script: Path, pkg: str) -> str:
"""Generate a Bazel `py_binary` rule for a `setup.py` generated script.
For details on `setup.py` generated scripts, see:
https://docs.python.org/3/distutils/setupscript.html#installing-scripts
Args:
src (Path): The path to the source file generated by
`generate_setup_py_script_contents`.
script (Path): The path to the `setup.py` script.
pkg (str): The package owning the entry point. This is expected to
match up with the `py_library` defined for each repository.
Returns:
str: A `py_binary` instantiation.
"""
name = os.path.splitext(src.name)[0]
return textwrap.dedent("""\
py_binary(
name = "{name}",
srcs = ["{src}"],
main = "{src}",
# This makes this directory a top-level in the python import
# search path for anything that depends on this.
imports = ["."],
deps = ["{pkg}"],
data = ["{script}"],
)
""".format(
name=name,
src=str(src.name).replace("\\", "/"),
pkg=pkg,
script=str(script).replace("\\", "/"),
))


def generate_build_file_contents(
name: str,
dependencies: List[str],
Expand Down Expand Up @@ -201,6 +271,11 @@ def entry_point(pkg, script = None):
script = pkg
return requirement(pkg) + ":{entry_point_prefix}_" + script
def setup_py_script(pkg, script = None):
if not script:
script = pkg
return requirement(pkg) + ":{setup_py_script_prefix}_" + script
def install_deps():
fail("install_deps() only works if you are creating an incremental repo. Did you mean to use pip_parse()?")
""".format(
Expand All @@ -211,6 +286,7 @@ def install_deps():
data_label=DATA_LABEL,
dist_info_label=DIST_INFO_LABEL,
entry_point_prefix=WHEEL_ENTRY_POINT_PREFIX,
setup_py_script_prefix=SETUP_PY_SCRIPT_PREFIX,
)
)

Expand Down Expand Up @@ -293,6 +369,7 @@ def extract_wheel(
extras: Dict[str, Set[str]],
pip_data_exclude: List[str],
enable_implicit_namespace_pkgs: bool,
repository_name: str,
incremental: bool = False,
incremental_repo_prefix: Optional[str] = None,
) -> Optional[str]:
Expand All @@ -303,6 +380,8 @@ def extract_wheel(
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
repository_name: The name of the repository building the wheel. Note that the format string `{pkg}` will be replaced
with the name of the wheel being extracted.
incremental: If true the extract the wheel in a format suitable for an external repository. This
effects the names of libraries and their dependencies, which point to other external repositories.
incremental_repo_prefix: If incremental is true, use this prefix when creating labels from wheel
Expand Down Expand Up @@ -352,8 +431,29 @@ def extract_wheel(
]

library_name = PY_LIBRARY_LABEL if incremental else sanitise_name(whl.name)

repository_name = repository_name.replace("{pkg}", whl.name.replace("-", "_"))
directory_path = Path(directory)

# Generate scripts and `py_binary` targets for any scripts produced by `setup.py`.
setup_py_scripts = []
wheel_data_dir = wheel.get_dot_data_directory(directory)
if wheel_data_dir:
scripts_dir = Path(wheel_data_dir) / "scripts"
for root, _dirnames, filenames in os.walk(str(scripts_dir)):
for filename in filenames:
setup_py_script = Path(root) / filename
setup_py_script_wrapper = directory_path / f"{SETUP_PY_SCRIPT_PREFIX}_{setup_py_script.name}.py"
setup_py_script_wrapper.write_text(generate_setup_py_script_contents(
script=setup_py_script,
repository_name=repository_name,
))
setup_py_scripts.append(generate_setup_py_script_rule(
src=setup_py_script_wrapper,
script=setup_py_script.relative_to(directory_path),
pkg=library_name,
))

# Generate scripts and `py_binary` targets for wheel entry points
entry_points = []
for name, entry_point in sorted(whl.entry_points().items()):
entry_point_script = f"{WHEEL_ENTRY_POINT_PREFIX}_{name}.py"
Expand All @@ -370,7 +470,7 @@ def extract_wheel(
sanitised_wheel_file_dependencies,
pip_data_exclude,
["pypi_name=" + whl.name, "pypi_version=" + whl.metadata.version],
entry_points,
entry_points + setup_py_scripts,
)
build_file.write(contents)

Expand Down
Loading

0 comments on commit 2063ce7

Please sign in to comment.