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:
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user