Skip to content

Commit d0cbf2c

Browse files
committed
feat: expose the precompiled-mode sources to non-CMake builds
Adds pybind11.get_source_dir() / python -m pybind11 --srcdir, a srcdir variable in pybind11.pc, and Pybind11Extension(precompile=True) which compiles src/pybind11_combined.cpp into the extension and defines PYBIND11_PRECOMPILED (hard error if the sources are missing). Assisted-by: ClaudeCode:claude-fable-5
1 parent 5d29e4f commit d0cbf2c

8 files changed

Lines changed: 203 additions & 5 deletions

File tree

CMakeLists.txt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,8 +343,8 @@ if(PYBIND11_INSTALL)
343343
install(DIRECTORY ${pybind11_INCLUDE_DIR}/pybind11 DESTINATION "${SKBUILD_HEADERS_DIR}")
344344
endif()
345345
install(DIRECTORY ${pybind11_INCLUDE_DIR}/pybind11 DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
346-
install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/
347-
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/pybind11/src")
346+
set(pybind11_install_srcdir "${CMAKE_INSTALL_DATAROOTDIR}/pybind11/src")
347+
install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src/ DESTINATION "${pybind11_install_srcdir}")
348348
set(PYBIND11_CMAKECONFIG_INSTALL_DIR
349349
"${CMAKE_INSTALL_DATAROOTDIR}/cmake/${PROJECT_NAME}"
350350
CACHE STRING "install path for pybind11Config.cmake")
@@ -355,9 +355,9 @@ if(PYBIND11_INSTALL)
355355
set(pybind11_INCLUDEDIR "\$\{PACKAGE_PREFIX_DIR\}/${CMAKE_INSTALL_INCLUDEDIR}")
356356
endif()
357357
if(IS_ABSOLUTE "${CMAKE_INSTALL_DATAROOTDIR}")
358-
set(pybind11_SRCDIR "${CMAKE_INSTALL_DATAROOTDIR}/pybind11/src")
358+
set(pybind11_SRCDIR "${pybind11_install_srcdir}")
359359
else()
360-
set(pybind11_SRCDIR "\$\{PACKAGE_PREFIX_DIR\}/${CMAKE_INSTALL_DATAROOTDIR}/pybind11/src")
360+
set(pybind11_SRCDIR "\$\{PACKAGE_PREFIX_DIR\}/${pybind11_install_srcdir}")
361361
endif()
362362

363363
configure_package_config_file(
@@ -410,6 +410,7 @@ if(PYBIND11_INSTALL)
410410
endif()
411411
endif()
412412
join_paths(includedir_for_pc_file "\${prefix}" "${CMAKE_INSTALL_INCLUDEDIR}")
413+
join_paths(srcdir_for_pc_file "\${prefix}" "${pybind11_install_srcdir}")
413414
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/tools/pybind11.pc.in"
414415
"${CMAKE_CURRENT_BINARY_DIR}/pybind11.pc" @ONLY)
415416
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/pybind11.pc"

pybind11/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88

99

1010
from ._version import __version__, version_info
11-
from .commands import get_cmake_dir, get_include, get_pkgconfig_dir
11+
from .commands import get_cmake_dir, get_include, get_pkgconfig_dir, get_source_dir
1212

1313
__all__ = (
1414
"__version__",
1515
"get_cmake_dir",
1616
"get_include",
1717
"get_pkgconfig_dir",
18+
"get_source_dir",
1819
"version_info",
1920
)

pybind11/__main__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
get_include_dirs,
1818
get_ldflags,
1919
get_pkgconfig_dir,
20+
get_source_dir,
2021
)
2122

2223

@@ -50,6 +51,12 @@ def main() -> None:
5051
action="store_true",
5152
help="Print the pkgconfig directory, ideal for setting $PKG_CONFIG_PATH.",
5253
)
54+
parser.add_argument(
55+
"--srcdir",
56+
action="store_true",
57+
help="Print the directory containing the library sources for the optional"
58+
" precompiled mode.",
59+
)
5360
parser.add_argument(
5461
"--extension-suffix",
5562
action="store_true",
@@ -101,6 +108,8 @@ def main() -> None:
101108
print(quote(get_cmake_dir()))
102109
if args.pkgconfigdir:
103110
print(quote(get_pkgconfig_dir()))
111+
if args.srcdir:
112+
print(quote(get_source_dir()))
104113
if args.extension_suffix:
105114
print(ext_suffix)
106115

pybind11/commands.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ def get_include(user: bool = False) -> str: # noqa: ARG001
5252
return installed_path if os.path.exists(installed_path) else source_path
5353

5454

55+
def get_source_dir() -> str:
56+
"""
57+
Return the path to the pybind11 library sources, for the optional
58+
precompiled mode. Compile ``pybind11_combined.cpp`` (or the individual
59+
``.cpp`` files) with ``PYBIND11_PRECOMPILED`` defined, and define that
60+
macro for every translation unit that includes pybind11.
61+
"""
62+
installed_path = os.path.join(DIR, "share", "pybind11", "src")
63+
source_path = os.path.join(os.path.dirname(DIR), "src")
64+
if os.path.exists(installed_path):
65+
return installed_path
66+
if os.path.exists(source_path):
67+
return source_path
68+
69+
msg = "pybind11 library sources not found (pybind11 not installed?)"
70+
raise ImportError(msg)
71+
72+
5573
def get_cmake_dir() -> str:
5674
"""
5775
Return the path to the pybind11 CMake module directory.

pybind11/setup_helpers.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ class Pybind11Extension(_Extension):
108108
109109
If you want to add pybind11 headers manually, for example for an exact
110110
git checkout, then set ``include_pybind11=False``.
111+
112+
Set ``precompile=True`` to compile the pybind11 library sources into the
113+
extension (one extra translation unit) instead of instantiating everything
114+
inline in every file; this usually builds faster. Requires an installed
115+
pybind11 package that ships the library sources. Use the ``build_ext``
116+
from this module when you build more than one precompiled extension in
117+
one ``setup()``; it gives each extension its own copy of the library
118+
translation unit, so each gets its own object file.
111119
"""
112120

113121
# flags are prepended, so that they can be further overridden, e.g. by
@@ -127,6 +135,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
127135
kwargs["language"] = "c++"
128136

129137
include_pybind11 = kwargs.pop("include_pybind11", True)
138+
precompile = kwargs.pop("precompile", False)
130139

131140
super().__init__(*args, **kwargs)
132141

@@ -143,6 +152,40 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
143152
except ModuleNotFoundError:
144153
pass
145154

155+
self._precompile_source: str | None = None
156+
if precompile:
157+
if not include_pybind11:
158+
# The shipped sources must match the shipped headers; mixing
159+
# them with a different checkout gives confusing errors.
160+
msg = (
161+
"precompile=True compiles the sources of the installed "
162+
"pybind11 package, so it cannot be combined with "
163+
"include_pybind11=False. Instead, add "
164+
"src/pybind11_combined.cpp from your pybind11 checkout "
165+
"to sources and define PYBIND11_PRECOMPILED."
166+
)
167+
raise ValueError(msg)
168+
# No silent fallback: failing to precompile would quietly rebuild
169+
# everything inline, so a missing source tree is an error.
170+
try:
171+
import pybind11
172+
173+
combined = os.path.join(
174+
pybind11.get_source_dir(), "pybind11_combined.cpp"
175+
)
176+
except (ImportError, AttributeError) as err:
177+
msg = (
178+
"precompile=True requires an installed pybind11 package "
179+
"that provides the library sources"
180+
)
181+
raise ValueError(msg) from err
182+
if not os.path.exists(combined):
183+
msg = f"pybind11 library sources not found: {combined}"
184+
raise ValueError(msg)
185+
self._precompile_source = combined
186+
self.sources.append(combined)
187+
self.define_macros.append(("PYBIND11_PRECOMPILED", None))
188+
146189
self.cxx_std = cxx_std
147190

148191
cflags = []
@@ -278,9 +321,30 @@ def build_extensions(self) -> None:
278321
for ext in self.extensions:
279322
if hasattr(ext, "_cxx_level") and ext._cxx_level == 0:
280323
ext.cxx_std = auto_cpp_level(self.compiler)
324+
self._isolate_precompile_source(ext)
281325

282326
super().build_extensions()
283327

328+
def _isolate_precompile_source(self, ext: _Extension) -> None:
329+
# Each precompiled extension needs its own combined source file:
330+
# setuptools maps a shared absolute source to one shared object file,
331+
# which races in parallel builds and can silently reuse an object
332+
# compiled with another extension's macros.
333+
src = getattr(ext, "_precompile_source", None)
334+
if src is None:
335+
return
336+
dest_dir = Path(self.build_temp) / "pybind11_precompile"
337+
dest_dir.mkdir(parents=True, exist_ok=True)
338+
dest = dest_dir / (ext.name.replace(".", "_") + "_combined.cpp")
339+
# A shim #include keeps the original's relative sibling includes valid
340+
contents = f'#include "{Path(src).resolve().as_posix()}"\n'
341+
if not dest.exists() or dest.read_text(encoding="utf-8") != contents:
342+
dest.write_text(contents, encoding="utf-8")
343+
# Give the shim the original's mtime, so mtime-based recompile checks
344+
# follow the real source.
345+
shutil.copystat(src, dest)
346+
ext.sources[ext.sources.index(src)] = str(dest)
347+
284348

285349
def intree_extensions(
286350
paths: Iterable[str], package_dir: dict[str, str] | None = None

tests/extra_python_package/test_files.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
PKGCONFIG = """\
3434
prefix=${{pcfiledir}}/../../
3535
includedir=${{prefix}}/include
36+
srcdir=${{prefix}}/share/pybind11/src
3637
3738
Name: pybind11
3839
Description: Seamless operability between C++11 and Python

tests/extra_setuptools/test_setuphelper.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,109 @@ def test_simple_setup_py(monkeypatch, tmpdir, parallel, std):
110110
)
111111

112112

113+
def test_precompile_setup_py(monkeypatch, tmpdir):
114+
# Two precompiled extensions with different configuration macros: each
115+
# must compile its own copy of the combined translation unit, or the
116+
# mtime-based recompile check reuses the first extension's object and the
117+
# link-time config guard fails.
118+
monkeypatch.chdir(tmpdir)
119+
monkeypatch.syspath_prepend(MAIN_DIR)
120+
121+
(tmpdir / "setup.py").write_text(
122+
dedent(
123+
f"""\
124+
import sys
125+
sys.path.append({MAIN_DIR!r})
126+
127+
from setuptools import setup
128+
from pybind11.setup_helpers import (
129+
ParallelCompile,
130+
Pybind11Extension,
131+
build_ext,
132+
naive_recompile,
133+
)
134+
135+
ParallelCompile(needs_recompile=naive_recompile).install()
136+
137+
ext_modules = [
138+
Pybind11Extension(
139+
"precompile_a",
140+
["a.cpp"],
141+
cxx_std=17,
142+
precompile=True,
143+
),
144+
Pybind11Extension(
145+
"precompile_b",
146+
["b.cpp"],
147+
cxx_std=17,
148+
precompile=True,
149+
define_macros=[("PYBIND11_DETAILED_ERROR_MESSAGES", None)],
150+
),
151+
]
152+
153+
setup(
154+
name="precompile_setup_package",
155+
cmdclass={{"build_ext": build_ext}},
156+
ext_modules=ext_modules,
157+
)
158+
"""
159+
),
160+
encoding="ascii",
161+
)
162+
163+
for name, mult in [("a", 3), ("b", 5)]:
164+
(tmpdir / f"{name}.cpp").write_text(
165+
dedent(
166+
f"""\
167+
#include <pybind11/pybind11.h>
168+
169+
#ifndef PYBIND11_PRECOMPILED
170+
# error "expected PYBIND11_PRECOMPILED to be defined"
171+
#endif
172+
173+
int f(int x) {{
174+
return x * {mult};
175+
}}
176+
PYBIND11_MODULE(precompile_{name}, m, pybind11::mod_gil_used()) {{
177+
m.def("f", &f);
178+
}}
179+
"""
180+
),
181+
encoding="ascii",
182+
)
183+
184+
subprocess.check_call(
185+
[sys.executable, "setup.py", "build_ext", "--inplace"],
186+
stdout=sys.stdout,
187+
stderr=sys.stderr,
188+
)
189+
190+
(tmpdir / "test.py").write_text(
191+
dedent(
192+
"""\
193+
import precompile_a
194+
import precompile_b
195+
assert precompile_a.f(3) == 9
196+
assert precompile_b.f(3) == 15
197+
"""
198+
),
199+
encoding="ascii",
200+
)
201+
202+
subprocess.check_call(
203+
[sys.executable, "test.py"], stdout=sys.stdout, stderr=sys.stderr
204+
)
205+
206+
207+
def test_precompile_include_pybind11_false(monkeypatch):
208+
monkeypatch.syspath_prepend(MAIN_DIR)
209+
210+
from pybind11.setup_helpers import Pybind11Extension
211+
212+
with pytest.raises(ValueError, match="include_pybind11=False"):
213+
Pybind11Extension("bad", ["bad.cpp"], precompile=True, include_pybind11=False)
214+
215+
113216
def test_intree_extensions(monkeypatch, tmpdir):
114217
monkeypatch.syspath_prepend(MAIN_DIR)
115218

tools/pybind11.pc.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
prefix=@prefix_for_pc_file@
22
includedir=@includedir_for_pc_file@
3+
srcdir=@srcdir_for_pc_file@
34

45
Name: @PROJECT_NAME@
56
Description: Seamless operability between C++11 and Python

0 commit comments

Comments
 (0)