Skip to content

Commit efedad8

Browse files
committed
Automatically generate pyi stubs for built extensions
- Fixes #1
1 parent 1c17693 commit efedad8

File tree

8 files changed

+181
-6
lines changed

8 files changed

+181
-6
lines changed

examples/demo/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
*.py[cod]
1+
*.py[ciod]
22
*.so
33
*.dylib
44
*.egg-info
55
*.whl
6+
py.typed
67

78
/build
89
/dist

robotpy_build/command/build_ext.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,17 @@ def build_extensions(self):
9393
install_root = get_install_root(self)
9494

9595
for ext in self.extensions:
96-
relink_extension(
96+
libs = relink_extension(
9797
install_root,
9898
self.get_ext_fullpath(ext.name),
9999
self.get_ext_filename(ext.name),
100100
ext.rpybuild_wrapper,
101101
self.rpybuild_pkgcfg,
102102
)
103103

104+
# Used in build_pyi
105+
ext.rpybuild_libs = libs
106+
104107
def run(self):
105108

106109
# files need to be generated before building can occur
@@ -111,6 +114,9 @@ def run(self):
111114

112115
build_ext.run(self)
113116

117+
# pyi can only be built after ext is built
118+
self.run_command("build_pyi")
119+
114120
def get_libraries(self, ext):
115121
libraries = build_ext.get_libraries(self, ext)
116122

robotpy_build/command/build_pyi.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import importlib.util
2+
import json
3+
import os
4+
from os.path import abspath, exists, dirname, join
5+
import subprocess
6+
import sys
7+
8+
import pybind11_stubgen
9+
from setuptools import Command
10+
from distutils.errors import DistutilsError
11+
12+
from .util import get_install_root
13+
14+
15+
class GeneratePyiError(DistutilsError):
16+
pass
17+
18+
19+
class BuildPyi(Command):
20+
21+
command_name = "build_pyi"
22+
description = "Generates pyi files from built extensions"
23+
24+
user_options = [("build-lib=", "d", 'directory to "build" (copy) to')]
25+
26+
def initialize_options(self):
27+
self.build_lib = None
28+
29+
def finalize_options(self):
30+
self.set_undefined_options("build", ("build_lib", "build_lib"))
31+
32+
def run(self):
33+
# cannot build pyi files when cross-compiling
34+
if (
35+
"_PYTHON_HOST_PLATFORM" in os.environ
36+
or os.environ.get("RPYBUILD_SKIP_PYI") == "1"
37+
):
38+
return
39+
40+
# Gather information for needed stubs
41+
data = {"mapping": {}, "stubs": []}
42+
43+
# OSX-specific: need to set DYLD_LIBRARY_PATH otherwise modules don't
44+
# work. Luckily, that information was computed when building the
45+
# extensions...
46+
env = os.environ.copy()
47+
dyld_path = set()
48+
49+
# Requires information from build_ext to work
50+
build_ext = self.distribution.get_command_obj("build_ext")
51+
if build_ext.inplace:
52+
data["out"] = get_install_root(self)
53+
else:
54+
data["out"] = self.build_lib
55+
56+
# Ensure that the associated packages can always be found locally
57+
for wrapper in build_ext.wrappers:
58+
pkgdir = wrapper.package_name.split(".")
59+
init_py = abspath(join(self.build_lib, *pkgdir, "__init__.py"))
60+
if exists(init_py):
61+
data["mapping"][wrapper.package_name] = init_py
62+
63+
# Ensure that the built extension can always be found
64+
for ext in build_ext.extensions:
65+
fname = build_ext.get_ext_filename(ext.name)
66+
data["mapping"][ext.name] = abspath(join(self.build_lib, fname))
67+
data["stubs"].append(ext.name)
68+
69+
rpybuild_libs = getattr(ext, "rpybuild_libs", None)
70+
if rpybuild_libs:
71+
for pth, _ in rpybuild_libs.values():
72+
dyld_path.add(dirname(pth))
73+
74+
# Don't do anything if nothing is needed
75+
if not data["stubs"]:
76+
return
77+
78+
# OSX-specific
79+
if dyld_path:
80+
dyld_path = ":".join(dyld_path)
81+
if "DYLD_LIBRARY_PATH" in env:
82+
dyld_path += ":" + env["DYLD_LIBRARY_PATH"]
83+
env["DYLD_LIBRARY_PATH"] = dyld_path
84+
85+
data_json = json.dumps(data)
86+
87+
# Execute in a subprocess in case it crashes
88+
args = [sys.executable, "-m", __name__]
89+
try:
90+
subprocess.run(args, input=data_json.encode("utf-8"), env=env, check=True)
91+
except subprocess.CalledProcessError:
92+
raise GeneratePyiError(
93+
"Failed to generate .pyi file (see above, or set RPYBUILD_SKIP_PYI=1 to ignore) via %s"
94+
% (args,)
95+
) from None
96+
97+
98+
class _PackageFinder:
99+
"""
100+
Custom loader to allow loading built modules from their location
101+
in the build directory (as opposed to their install location)
102+
"""
103+
104+
mapping = {}
105+
106+
@classmethod
107+
def find_spec(cls, fullname, path, target=None):
108+
m = cls.mapping.get(fullname)
109+
if m:
110+
return importlib.util.spec_from_file_location(fullname, m)
111+
112+
113+
def generate_pyi(module_name: str, pyi_filename: str):
114+
115+
print("generating", pyi_filename)
116+
117+
pybind11_stubgen.FunctionSignature.n_invalid_signatures = 0
118+
module = pybind11_stubgen.ModuleStubsGenerator(module_name)
119+
module.parse()
120+
if pybind11_stubgen.FunctionSignature.n_invalid_signatures > 0:
121+
print("FAILED to generate pyi for", module_name, file=sys.stderr)
122+
return False
123+
124+
module.write_setup_py = False
125+
with open(pyi_filename, "w") as fp:
126+
fp.write("#\n# AUTOMATICALLY GENERATED FILE, DO NOT EDIT!\n#\n\n")
127+
fp.write("\n".join(module.to_lines()))
128+
129+
typed = join(dirname(pyi_filename), "py.typed")
130+
print("generating", typed)
131+
if not exists(typed):
132+
with open(typed, "w") as fp:
133+
pass
134+
135+
return True
136+
137+
138+
def main():
139+
140+
cfg = json.load(sys.stdin)
141+
142+
# Configure custom loader
143+
_PackageFinder.mapping = cfg["mapping"]
144+
sys.meta_path.insert(0, _PackageFinder)
145+
146+
# Generate pyi modules
147+
sys.argv = [
148+
"<dummy>",
149+
"--no-setup-py",
150+
"--log-level=WARNING",
151+
"--root-module-suffix=",
152+
"--ignore-invalid",
153+
"defaultarg",
154+
"-o",
155+
cfg["out"],
156+
] + cfg["stubs"]
157+
158+
pybind11_stubgen.main()
159+
160+
161+
if __name__ == "__main__":
162+
main()

robotpy_build/relink_libs.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def relink_extension(
137137
extension_rel: str,
138138
pkg: PkgCfg,
139139
pkgcfg: PkgCfgProvider,
140-
):
140+
) -> LibsDict:
141141
"""
142142
Given an extension, relink it
143143
@@ -158,3 +158,4 @@ def relink_extension(
158158
)
159159
}
160160
_fix_libs(to_fix, libs)
161+
return libs

robotpy_build/setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def finalize_options(self):
2222
from .command.build_dl import BuildDl
2323
from .command.build_gen import BuildGen
2424
from .command.build_ext import BuildExt
25+
from .command.build_pyi import BuildPyi
2526
from .command.develop import Develop
2627

2728
from .overrides import apply_overrides
@@ -112,6 +113,7 @@ def prepare(self):
112113
"build_dl": BuildDl,
113114
"build_gen": BuildGen,
114115
"build_ext": BuildExt,
116+
"build_pyi": BuildPyi,
115117
"develop": Develop,
116118
}
117119
if bdist_wheel:

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ install_requires =
3232
toposort
3333
pyyaml >= 5.1
3434
patch == 1.*
35+
pybind11-stubgen >= 0.8.1
3536
dataclasses; python_version < '3.7'
3637
delocate; platform_system == 'Darwin'
3738
setup_requires =

tests/cpp/.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11

2-
*.pyc
2+
*.py[ciod]
33

44
*.so
55
*.dll
66
*.pyd
77
*.dylib
8+
py.typed
9+
810

911
/build
1012
/dist

tests/cpp/rpytest/ft/include/inheritance/ibase.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ struct IBase
5252
return 7;
5353
}
5454

55-
virtual void protectedOutMethod(int *out, int in)
55+
virtual void protectedOutMethod(int *out, int inp)
5656
{
57-
*out = in + 5;
57+
*out = inp + 5;
5858
}
5959
};
6060

0 commit comments

Comments
 (0)