feat: small command line helpers for quick tests (#4272)
* feat: small command line helpers for quick tests
Add --cflags, --ldflags, --embed, and --file to the pybind11 command
line tool, based on python-config. This makes quick one-off compiles
easy:
c++ $(python3 -m pybind11 --file=example.cpp)
Assisted-by: ClaudeCode:claude-fable-5
* refactor: quote paths, clarify CLI output logic, strengthen tests
- Quote include and library dirs in get_cflags/get_ldflags so paths with
spaces work, sharing one quote helper in commands.py
- Restructure the --file/--cflags/--ldflags printing into a single print
- Note the Unix-compiler orientation in help text and docs
- Test -L presence for --embed and quoting of paths with spaces
Assisted-by: ClaudeCode:claude-fable-5
* refactor: simplify flag helpers and keep import pybind11 light
- Replace _get_config_var(name, fmt, quote) with a plain _config(name)
string helper; formatting and quoting happen at the call sites
- Defer sysconfig/shlex imports so import pybind11 does not pay for the
CLI (~1ms -> ~0.08ms for pybind11.commands)
- Fetch EXT_SUFFIX once in main()
- Import commands normally in the quoting test instead of importlib
file loading
Assisted-by: ClaudeCode:claude-fable-5
* fix: shared-library flags on all Unix platforms
The --file/--ldflags output only added -shared and -fPIC on Linux, so
other Unix platforms (FreeBSD, Solaris, AIX) linked an executable and
failed on the missing main. Gate on os.name instead.
Assisted-by: ClaudeCode:claude-opus-5diff --git a/docs/compiling.rst b/docs/compiling.rst
index b693bd5..1838777 100644
--- a/docs/compiling.rst
+++ b/docs/compiling.rst
@@ -665,6 +665,25 @@
$ 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 21c3cd9..ce597c7 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 @@
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 d535b6c..8bd0a9b 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 @@
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 d96e9af..0abb48c 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 @@
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")