Skip to content

Commit

Permalink
Expose nanobind's stubgen as a py_binary (#35)
Browse files Browse the repository at this point in the history
* Expose nanobind's stubgen as a py_binary

Adds `typing_extensions` as a unconditional dependency
for the stubgen target.

The plan is to wrap the py_binary into a nanobind stubgen
rule that lives in the same file as all other rules.

* Add docstring, inline arg construction for py_binary

Everything is being forwarded to the py_binary that previously existed in the
top-level BUILD file.

There are still some difficulties locating the module object, since it
exists in multiple places. Maybe it too needs to be sourced from $BINDIR.

* Use template string for all files, assign labels to variables

* Move stubgen to nanobind package, depend on it in stubgen target

Stubgen does not understand file system paths to modules, so we need to
make an indirection and convert paths to nanobind extension targets to
Pythonic module paths via a Python script.

* Add stubgen wrapper script to create stubs directly in $(BINDIR)

Extracts runfiles dir and bindir from the invoker's script path,
and proceeds to put the generated stub file directly into the $(BINDIR).

What is left outstanding is to support pattern and marker files, but
that has lower prio for now.

* Deduce bindir from stubgen wrapper CWD

This makes the bindir available from Windows.

Also, harden the stub output path generation on Windows in the case that
the binary location in runfiles is not a symlink.

Also also, add a pattern file to the binary's data deps if provided,
ensuring that the file exists in the repo as an addressable target, and
is visible to the stubgen wrapper in the runfiles dir.
  • Loading branch information
nicholasjng authored Jul 24, 2024
1 parent 0656359 commit dc9d28d
Show file tree
Hide file tree
Showing 8 changed files with 290 additions and 28 deletions.
54 changes: 27 additions & 27 deletions .github/workflows/lint-and-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,33 +37,33 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]
os: [ubuntu-latest, macos-latest, windows-latest]
py: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
with:
path: nanobind-bazel
- name: Set up Python ${{ matrix.py }} on ${{ matrix.os }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.py }}
- name: Check out nanobind example repo
uses: actions/checkout@v4
with:
repository: wjakob/nanobind_example
path: nanobind_example
ref: bazel
- uses: actions/checkout@v4
with:
path: nanobind-bazel
- name: Set up Python ${{ matrix.py }} on ${{ matrix.os }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.py }}
- name: Check out nanobind example repo
uses: actions/checkout@v4
with:
repository: wjakob/nanobind_example
path: nanobind_example
ref: bazel

- name: Build and test nanobind_example on ${{ matrix.os }}
run: |
python -m pip wheel . -w dist
python -m pip install --find-links=dist/ nanobind_example
python -c "import nanobind_example; assert nanobind_example.add(1, 2) == 3"
working-directory: ${{ github.workspace }}/nanobind_example
- name: Check ${{ matrix.os }} CPython>=3.12 wheels for stable ABI violations
if: matrix.py == '3.12'
run: |
python -m pip install --upgrade abi3audit
python -m abi3audit dist/*.whl --verbose
shell: bash
working-directory: ${{ github.workspace }}/nanobind_example
- name: Build and test nanobind_example on ${{ matrix.os }}
run: |
python -m pip wheel . -w dist
python -m pip install --find-links=dist/ nanobind_example
python -c "import nanobind_example; assert nanobind_example.add(1, 2) == 3"
working-directory: ${{ github.workspace }}/nanobind_example
- name: Check ${{ matrix.os }} CPython>=3.12 wheels for sqtable ABI violations
if: matrix.py == '3.12'
run: |
python -m pip install --upgrade abi3audit
python -m abi3audit dist/*.whl --verbose
shell: bash
working-directory: ${{ github.workspace }}/nanobind_example
1 change: 1 addition & 0 deletions BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ licenses(["notice"])
exports_files([
"LICENSE",
"pybind11_bazel.LICENSE",
"stubgen_wrapper.py",
])

bool_flag(
Expand Down
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ bazel_dep(name = "bazel_skylib", version = "1.6.1")

# Creates a `http_archive` for nanobind and robin-map.
internal_configure = use_extension("//:internal_configure.bzl", "internal_configure_extension")
use_repo(internal_configure, "nanobind")
use_repo(internal_configure, "nanobind", "pypi__typing_extensions")
86 changes: 86 additions & 0 deletions build_defs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ Build defs for nanobind.
The ``nanobind_extension`` corresponds to a ``cc_binary``,
the ``nanobind_library`` to a ``cc_library``,
the ``nanobind_shared_library`` to a ``cc_shared_library``,
the ``nanobind_stubgen`` to a ``py_binary``,
and the ``nanobind_test`` to a ``cc_test``.
For creating Python bindings, the most likely case is a ``nanobind_extension``
Expand All @@ -17,6 +19,7 @@ load(
"nb_common_opts",
"nb_sizeopts",
)
load("@rules_python//python:py_binary.bzl", "py_binary")

NANOBIND_COPTS = nb_common_opts() + nb_sizeopts()
NANOBIND_DEPS = [Label("@nanobind//:nanobind")]
Expand Down Expand Up @@ -141,6 +144,89 @@ def nanobind_shared_library(
**kwargs
)

def nanobind_stubgen(
name,
module,
output_file = None,
imports = [],
pattern_file = None,
marker_file = None,
include_private_members = False,
exclude_docstrings = False):
"""Creates a stub file containing Python type annotations for a nanobind extension.
Args:
name: str
Name of this stub generation target, unused.
module: Label
Label of the extension module for which the stub file should be
generated.
output_file: str or None
Output file path for the generated stub, relative to $(BINDIR).
If none is given, the stub will be placed under the same location
as the module in your source tree.
imports: list
List of modules to import for stub generation.
pattern_file: Label or None
Label of a pattern file used for programmatically editing generated stubs.
For more information, consider the documentation under
https://nanobind.readthedocs.io/en/latest/typing.html#pattern-files.
marker_file: str or None
An empty typing marker file to add to the project, most often named
"py.typed". Must be given relative to your Python project root.
include_private_members: bool
Whether to include private module members, i.e. those starting and/or
ending with an underscore ("_").
exclude_docstrings: bool
Whether to exclude all docstrings of all module members from the generated
stub file.
"""
STUBGEN_WRAPPER = Label("@nanobind_bazel//:stubgen_wrapper.py")
loc = "$(rlocationpath {})"

# stubgen wrapper dependencies: nanobind.stubgen, typing_extensions (via nanobind),
# rules_python runfiles (unused, needed later when giving an explicit output path)
deps = [
Label("@nanobind//:stubgen"),
Label("@pypi__typing_extensions//:lib"),
Label("@rules_python//python/runfiles"),
]

data = [module]

args = ["-m " + loc.format(module)]

# to be searchable by path expansion, a file must be
# declared by a rule beforehand. This might not be the
# case for a generated stub, so we just give the raw name here
if output_file:
args.append("-o {}".format(output_file))

# Add pattern and marker files.
# The pattern file must exist in the Bazel repo, so
# we pass its label to the py_binary's data dependencies.
# The marker file can be generated on the fly, however.
if pattern_file:
data.append(pattern_file)
args.append("-p " + loc.format(pattern_file))
if marker_file:
args.append("-M {}".format(marker_file))

if include_private_members:
args.append("--include-private")
if exclude_docstrings:
args.append("--exclude-docstrings")

py_binary(
name = name,
srcs = [STUBGEN_WRAPPER],
main = STUBGEN_WRAPPER,
deps = deps,
data = data,
imports = imports,
args = args,
)

def nanobind_test(
name,
copts = [],
Expand Down
9 changes: 9 additions & 0 deletions internal_configure.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,13 @@ def _internal_configure_extension_impl(_):
urls = ["https://github.com/wjakob/nanobind/archive/refs/tags/v%s.tar.gz" % nanobind_version],
)

typing_extensions_version = "4.12.2"
http_archive(
name = "pypi__typing_extensions",
build_file = "//:typing_extensions.BUILD",
strip_prefix = "typing_extensions-%s" % typing_extensions_version,
integrity = "sha256-Gn6tVcflWd1N7ohW46iLQSJav+HOjfV7fBORX+Eh/7g=",
urls = ["https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-%s.tar.gz" % typing_extensions_version],
)

internal_configure_extension = module_extension(implementation = _internal_configure_extension_impl)
7 changes: 7 additions & 0 deletions nanobind.BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,10 @@ cc_library(
"@rules_python//python/cc:current_py_cc_headers",
],
)

py_library(
name = "stubgen",
srcs = ["src/stubgen.py"],
imports = ["src"],
deps = ["@pypi__typing_extensions//:lib"],
)
121 changes: 121 additions & 0 deletions stubgen_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import os
import sys

from pathlib import Path
from typing import Union

from stubgen import main
from python.runfiles import runfiles

DEBUG = bool(os.getenv("DEBUG", False))
RLOCATION_ROOT = Path("_main") # the Python path root under the script's runfiles.

def get_runfiles_dir(path: Union[str, os.PathLike]):
"""Obtain the runfiles root from the Python script path."""
ppath = Path(path)
for p in ppath.parents:
if p.parts[-1].endswith("runfiles"):
return p
raise RuntimeError("could not locate runfiles directory")


def get_bindir(path: Union[str, os.PathLike]):
"""Obtain $(BINDIR) as an absolute path, from the current working directory.
NB: runfiles are not necessarily in the build tree on Windows,
so this needs to be deduced from the CWD of the script.
"""
ppath = Path(path)
for p in ppath.parents:
if p.parts[-1].endswith("bin"):
return p
raise RuntimeError("could not locate $(BINDIR)")


def convert_path_to_module(path: Union[str, os.PathLike]):
"""
Converts a shared object file name to a Python module name
understood by importlib.
Example:
For a shared lib pkg/foo.so, this returns pkg.foo.
"""
pp = Path(path)
# this trick strips up to two extensions from the file name.
# Since possible extensions at this point are
# .so, .abi3.so, and .pyd, this path always gives us the
# name of the shared lib without any extension.
extless = pp.with_name(pp.with_suffix("").stem)
# TODO: Normalize to snakecase
return ".".join(extless.parts)


def wrapper():
"""
A small wrapper to convert nanobind extension targets to module names
relative to the runfiles directory.
nanobind's stubgen script can only deal with module names
found on PYTHONPATH. Since Make variable expansion in Bazel
only works for paths, this does us no good.
The target extension and output file should be figured out directly
from the user's nanobind_stubgen rule definition - in fact, making
the user fiddle with rules is error-prone and unhelpful if they
have no Bazel experience.
Goes through the script's argv, finds the module name(s),
and converts each of them to a valid Python 3 module name.
"""
script, *args = sys.argv
runfiles_dir = get_runfiles_dir(script)
bindir = get_bindir(os.getcwd())
if DEBUG:
print(f"runfiles_dir = {runfiles_dir}")
print(f"bindir = {bindir}")
fname = ""
for i, arg in enumerate(args):
if arg.startswith("-m"):
fname = args.pop(i + 1)
if not fname.endswith((".so", ".pyd")):
raise ValueError(
f"invalid extension file {fname!r}: "
"only shared object files with extensions "
".so, .abi3.so, or .pyd are supported"
)
modname = convert_path_to_module(fname)
args.insert(i + 1, modname)

if "-o" not in args:
ext_path = runfiles_dir / fname
if DEBUG:
print(f"ext_path = {ext_path}")
if (ext_path).is_symlink():
# Path.readlink() is available on Python 3.9+ only.
objfile = Path(os.readlink(ext_path))
else:
objfile = bindir / Path(fname).relative_to(RLOCATION_ROOT)
if not objfile.exists():
raise RuntimeError("could not locate original path to object file")

stub_outpath = objfile.with_suffix("").with_suffix(".pyi")
if DEBUG:
print(f"stub_outpath = {stub_outpath}")

args.extend(["-o", str(stub_outpath)])
else:
# we have an output file, use its path instead relative to $(BINDIR),
# but in absolute form.
idx = args.index("-o")
args[idx + 1] = str(bindir / args[idx + 1])

if "-M" in args:
# fix up the path to the marker file relative to $(BINDIR).
idx = args.index("-M")
args[idx + 1] = str(bindir / args[idx + 1])

main(args)


if __name__ == "__main__":
wrapper()
38 changes: 38 additions & 0 deletions typing_extensions.BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
A version of the Python sdist Bazel template found in
https://github.com/bazelbuild/rules_python/blob/main/python/private/pypi/deps.bzl ,
specialized on the ``typing_extensions`` package.

# Copyright 2023 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.
"""

load("@rules_python//python:defs.bzl", "py_library")

package(default_visibility = ["//visibility:public"])

py_library(
name = "lib",
srcs = ["src/typing_extensions.py"],
data = [
"CHANGELOG.md",
"LICENSE",
"PKG-INFO",
"README.md",
"pyproject.toml",
],
# This makes the source directory a top-level in the Python import
# search path for anything that depends on this.
imports = ["src"],
)

0 comments on commit dc9d28d

Please sign in to comment.