diff --git a/docs/compiling.rst b/docs/compiling.rst index b693bd5878..1838777e63 100644 --- a/docs/compiling.rst +++ b/docs/compiling.rst @@ -665,6 +665,25 @@ building the module: $ c++ -O3 -Wall -shared -std=c++11 -undefined dynamic_lookup $(python3 -m pybind11 --includes) example.cpp -o example$(python3-config --extension-suffix) +For quick tests, the command line tool can also produce the full set of flags +for you, based on ``python-config``: + +.. code-block:: bash + + $ c++ $(python3 -m pybind11 --cflags) example.cpp $(python3 -m pybind11 --ldflags) -o example$(python3 -m pybind11 --extension-suffix) + +Or, shorter still, ``--file`` prints everything after the compiler for a given +source file, including the output name (add ``--embed`` for a program that +embeds the interpreter instead of an extension): + +.. code-block:: bash + + $ c++ $(python3 -m pybind11 --file=example.cpp) + +These helpers target Unix-style compilers (GCC/Clang) and are intended for +quick tests, not production builds; ``--file`` places the output next to the +source file. + In general, it is advisable to include several additional build parameters that can considerably reduce the size of the created binary. Refer to section :ref:`cmake` for a detailed example of a suitable cross-platform CMake-based diff --git a/pybind11/__main__.py b/pybind11/__main__.py index 21c3cd9abc..ce597c781a 100644 --- a/pybind11/__main__.py +++ b/pybind11/__main__.py @@ -3,50 +3,25 @@ import argparse import functools -import re import sys import sysconfig +from pathlib import Path from ._version import __version__ -from .commands import get_cmake_dir, get_include, get_pkgconfig_dir - -# This is the conditional used for os.path being posixpath -if "posix" in sys.builtin_module_names: - from shlex import quote -elif "nt" in sys.builtin_module_names: - # See https://github.com/mesonbuild/meson/blob/db22551ed9d2dd7889abea01cc1c7bba02bf1c75/mesonbuild/utils/universal.py#L1092-L1121 - # and the original documents: - # https://docs.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments and - # https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ - UNSAFE = re.compile("[ \t\n\r]") - - def quote(s: str) -> str: - if s and not UNSAFE.search(s): - return s - - # Paths cannot contain a '"' on Windows, so we don't need to worry - # about nuanced counting here. - return f'"{s}\\"' if s.endswith("\\") else f'"{s}"' -else: - - def quote(s: str) -> str: - return s +from .commands import ( + _quote as quote, +) +from .commands import ( + get_cflags, + get_cmake_dir, + get_include_dirs, + get_ldflags, + get_pkgconfig_dir, +) def print_includes() -> None: - dirs = [ - sysconfig.get_path("include"), - sysconfig.get_path("platinclude"), - get_include(), - ] - - # Make unique but preserve order - unique_dirs = [] - for d in dirs: - if d and d not in unique_dirs: - unique_dirs.append(d) - - print(" ".join(quote(f"-I{d}") for d in unique_dirs)) + print(" ".join(quote(f"-I{d}") for d in get_include_dirs())) def main() -> None: @@ -80,17 +55,54 @@ def main() -> None: action="store_true", help="Print the extension for a Python module", ) + parser.add_argument( + "--cflags", + action="store_true", + help="Print the compile flags for a simple extension (Unix-style compilers).", + ) + parser.add_argument( + "--ldflags", + action="store_true", + help="Print the link flags for a simple extension (Unix-style compilers).", + ) + parser.add_argument( + "--embed", + action="store_true", + help="Build for embedding instead of an extension; affects --ldflags and --file.", + ) + parser.add_argument( + "--file", + type=Path, + help="Print a full command-line suffix for compiling the given file;" + " the output goes next to the source file (Unix-style compilers).", + ) args = parser.parse_args() if not sys.argv[1:]: parser.print_help() - if args.includes: + ext_suffix = sysconfig.get_config_var("EXT_SUFFIX") or "" + if args.file: + suffix = "" if args.embed else ext_suffix + print( + get_cflags(), + quote(str(args.file)), + get_ldflags(embed=args.embed), + "-o", + quote(str(args.file.with_suffix(suffix))), + ) + else: + if args.cflags: + print(get_cflags()) + if args.ldflags: + print(get_ldflags(embed=args.embed)) + # --cflags and --file already contain the include flags + if args.includes and not (args.cflags or args.file): print_includes() if args.cmakedir: print(quote(get_cmake_dir())) if args.pkgconfigdir: print(quote(get_pkgconfig_dir())) if args.extension_suffix: - print(sysconfig.get_config_var("EXT_SUFFIX")) + print(ext_suffix) if __name__ == "__main__": diff --git a/pybind11/commands.py b/pybind11/commands.py index d535b6cca4..8bd0a9bf13 100644 --- a/pybind11/commands.py +++ b/pybind11/commands.py @@ -1,9 +1,46 @@ from __future__ import annotations import os +import re +import sys DIR = os.path.abspath(os.path.dirname(__file__)) +# shlex/sysconfig are imported lazily below to keep `import pybind11` light + +# This is the conditional used for os.path being posixpath +if "posix" in sys.builtin_module_names: + + def _quote(s: str) -> str: + import shlex + + return shlex.quote(s) +elif "nt" in sys.builtin_module_names: + # See https://github.com/mesonbuild/meson/blob/db22551ed9d2dd7889abea01cc1c7bba02bf1c75/mesonbuild/utils/universal.py#L1092-L1121 + # and the original documents: + # https://docs.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments and + # https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ + _UNSAFE = re.compile("[ \t\n\r]") + + def _quote(s: str) -> str: + if s and not _UNSAFE.search(s): + return s + + # Paths cannot contain a '"' on Windows, so we don't need to worry + # about nuanced counting here. + return f'"{s}\\"' if s.endswith("\\") else f'"{s}"' +else: + + def _quote(s: str) -> str: + return s + + +def _config(name: str) -> str: + """A stripped sysconfig variable, or "" if unset.""" + import sysconfig + + return str(sysconfig.get_config_var(name) or "").strip() + def get_include(user: bool = False) -> str: # noqa: ARG001 """ @@ -37,3 +74,67 @@ def get_pkgconfig_dir() -> str: msg = "pybind11 not installed, installation required to access the pkgconfig files" raise ImportError(msg) + + +def get_include_dirs() -> list[str]: + """ + Return the unique include directories for Python and pybind11. + """ + import sysconfig + + dirs = [ + sysconfig.get_path("include"), + sysconfig.get_path("platinclude"), + get_include(), + ] + + # Make unique but preserve order + unique_dirs = [] + for d in dirs: + if d and d not in unique_dirs: + unique_dirs.append(d) + return unique_dirs + + +def get_cflags() -> str: + """ + Return the compile flags for building a simple extension with a + Unix-style command-line compiler (GCC/Clang). Based on python-config. + """ + flags = [_quote(f"-I{d}") for d in get_include_dirs()] + # CFLAGS is a pre-composed multi-flag string, passed through as-is (like + # python-config does) + if cflags := _config("CFLAGS"): + flags.append(cflags) + flags.append("-std=c++17") + return " ".join(flags) + + +def get_ldflags(embed: bool = False) -> str: + """ + Return the link flags for building a simple extension (or, with + embed=True, an embedding program) with a Unix-style command-line + compiler (GCC/Clang). Based on python-config. + """ + # LDFLAGS/LIBS/SYSLIBS are pre-composed multi-flag strings, passed + # through as-is (like python-config does) + flags = [ldflags] if (ldflags := _config("LDFLAGS")) else [] + + if embed: + if libdir := _config("LIBDIR"): + flags.append(_quote(f"-L{libdir}")) + if not _config("Py_ENABLE_SHARED") and (libpl := _config("LIBPL")): + flags.append(_quote(f"-L{libpl}")) + abiflags = getattr(sys, "abiflags", "") or "" + flags.append(f"-lpython{_config('VERSION')}{abiflags}") + if libs := _config("LIBS"): + flags.append(libs) + if syslibs := _config("SYSLIBS"): + flags.append(syslibs) + elif sys.platform.startswith("darwin"): + flags += ["-undefined", "dynamic_lookup", "-shared"] + elif os.name == "posix": + # Linux and other Unix platforms (FreeBSD, Solaris, AIX, ...) + flags += ["-fPIC", "-shared"] + + return " ".join(flags) diff --git a/tests/extra_python_package/test_files.py b/tests/extra_python_package/test_files.py index d96e9afc1f..0abb48c1f4 100644 --- a/tests/extra_python_package/test_files.py +++ b/tests/extra_python_package/test_files.py @@ -1,16 +1,20 @@ from __future__ import annotations import contextlib +import importlib import os import re import shutil import subprocess import sys +import sysconfig import tarfile import zipfile from pathlib import Path from typing import Generator +import pytest + # These tests must be run explicitly DIR = Path(__file__).parent.resolve() @@ -384,3 +388,63 @@ def test_version_matches(): expected_patch = f"{micro}{level_str}{release_serial}" assert patch == expected_patch + + +def run_command_line(*args: str) -> str: + env = os.environ.copy() + env["PYTHONPATH"] = str(MAIN_DIR) + result = subprocess.run( + [sys.executable, "-m", "pybind11", *args], + capture_output=True, + text=True, + check=True, + env=env, + ) + return result.stdout + + +def test_cli_cflags(): + out = run_command_line("--cflags") + assert "-std=c++17" in out + assert f"-I{sysconfig.get_path('include')}" in out + + +def test_cli_ldflags_embed(): + out = run_command_line("--ldflags", "--embed") + assert "-lpython" in out + if sysconfig.get_config_var("LIBDIR"): + assert "-L" in out + + +@pytest.mark.skipif(os.name != "posix", reason="quote style is platform-specific") +def test_cflags_quotes_paths_with_spaces(monkeypatch): + monkeypatch.syspath_prepend(str(MAIN_DIR)) + commands = importlib.import_module("pybind11.commands") + monkeypatch.setattr(sysconfig, "get_path", lambda name: f"/spa ced/{name}") + assert "'-I/spa ced/include'" in commands.get_cflags() + + +@pytest.mark.skipif(os.name != "posix", reason="Unix link flags only") +def test_ldflags_other_unix(monkeypatch): + monkeypatch.syspath_prepend(str(MAIN_DIR)) + commands = importlib.import_module("pybind11.commands") + monkeypatch.setattr(sys, "platform", "freebsd14") + monkeypatch.setattr(commands, "_config", lambda name: "") # noqa: ARG005 + out = commands.get_ldflags() + assert "-shared" in out + assert "-fPIC" in out + + +def test_cli_file(): + out = run_command_line("--file", "example.cpp").rstrip() + ext_suffix = sysconfig.get_config_var("EXT_SUFFIX") + assert "-std=c++17" in out + assert out.index("-std=c++17") < out.index("example.cpp") + assert out.endswith(f"-o example{ext_suffix}") + if sys.platform.startswith(("linux", "darwin")): + assert out.index("example.cpp") < out.index("-shared") + + +def test_cli_file_embed(): + out = run_command_line("--file", "example.cpp", "--embed").rstrip() + assert out.endswith("-o example")