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-5
This commit is contained in:
Henry Schreiner
2026-07-31 14:17:21 -04:00
committed by GitHub
parent 652c69437b
commit ea9e6e6a23
4 changed files with 235 additions and 39 deletions
+19
View File
@@ -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
+51 -39
View File
@@ -3,50 +3,25 @@ from __future__ import annotations
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__":
+101
View File
@@ -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)
+64
View File
@@ -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")