Files
pybind11/tests/extra_python_package/test_files.py
T
Henry Schreiner ea9e6e6a23 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
2026-07-31 14:17:21 -04:00

451 lines
14 KiB
Python

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()
MAIN_DIR = DIR.parent.parent
FILENAME_VERSION = re.compile(r"[-_]((\d+\.\d+\.\d+)(?:[a-z]+\d*)?)(?:-|\.tar\.gz$)")
# Newer pytest has global path setting, but keeping old pytest for now
sys.path.append(str(MAIN_DIR / "tools"))
from make_global import get_global # noqa: E402
HAS_UV = shutil.which("uv") is not None
UV_ARGS = ["--installer=uv"] if HAS_UV else []
PKGCONFIG = """\
prefix=${{pcfiledir}}/../../
includedir=${{prefix}}/include
Name: pybind11
Description: Seamless operability between C++11 and Python
Version: {VERSION}
Cflags: -I${{includedir}}
"""
main_headers = {
"include/pybind11/attr.h",
"include/pybind11/buffer_info.h",
"include/pybind11/cast.h",
"include/pybind11/chrono.h",
"include/pybind11/common.h",
"include/pybind11/complex.h",
"include/pybind11/critical_section.h",
"include/pybind11/eigen.h",
"include/pybind11/embed.h",
"include/pybind11/eval.h",
"include/pybind11/functional.h",
"include/pybind11/gil.h",
"include/pybind11/gil_safe_call_once.h",
"include/pybind11/gil_simple.h",
"include/pybind11/iostream.h",
"include/pybind11/native_enum.h",
"include/pybind11/numpy.h",
"include/pybind11/operators.h",
"include/pybind11/options.h",
"include/pybind11/pybind11.h",
"include/pybind11/pytypes.h",
"include/pybind11/subinterpreter.h",
"include/pybind11/stl.h",
"include/pybind11/stl_bind.h",
"include/pybind11/trampoline_self_life_support.h",
"include/pybind11/type_caster_pyobject_ptr.h",
"include/pybind11/typing.h",
"include/pybind11/warnings.h",
}
conduit_headers = {
"include/pybind11/conduit/README.txt",
"include/pybind11/conduit/pybind11_conduit_v1.h",
"include/pybind11/conduit/pybind11_platform_abi_id.h",
"include/pybind11/conduit/wrap_include_python_h.h",
}
detail_headers = {
"include/pybind11/detail/argument_vector.h",
"include/pybind11/detail/class.h",
"include/pybind11/detail/common.h",
"include/pybind11/detail/cpp_conduit.h",
"include/pybind11/detail/descr.h",
"include/pybind11/detail/dynamic_raw_ptr_cast_if_possible.h",
"include/pybind11/detail/function_record_pyobject.h",
"include/pybind11/detail/function_ref.h",
"include/pybind11/detail/holder_caster_foreign_helpers.h",
"include/pybind11/detail/init.h",
"include/pybind11/detail/internals.h",
"include/pybind11/detail/native_enum_data.h",
"include/pybind11/detail/pybind11_namespace_macros.h",
"include/pybind11/detail/struct_smart_holder.h",
"include/pybind11/detail/type_caster_base.h",
"include/pybind11/detail/typeid.h",
"include/pybind11/detail/using_smart_holder.h",
"include/pybind11/detail/value_and_holder.h",
"include/pybind11/detail/exception_translation.h",
}
eigen_headers = {
"include/pybind11/eigen/common.h",
"include/pybind11/eigen/matrix.h",
"include/pybind11/eigen/tensor.h",
}
stl_headers = {
"include/pybind11/stl/filesystem.h",
}
cmake_files = {
"share/cmake/pybind11/FindPythonLibsNew.cmake",
"share/cmake/pybind11/pybind11Common.cmake",
"share/cmake/pybind11/pybind11Config.cmake",
"share/cmake/pybind11/pybind11ConfigVersion.cmake",
"share/cmake/pybind11/pybind11GuessPythonExtSuffix.cmake",
"share/cmake/pybind11/pybind11NewTools.cmake",
"share/cmake/pybind11/pybind11Targets.cmake",
"share/cmake/pybind11/pybind11Tools.cmake",
}
pkgconfig_files = {
"share/pkgconfig/pybind11.pc",
}
py_files = {
"__init__.py",
"__main__.py",
"_version.py",
"commands.py",
"py.typed",
"setup_helpers.py",
"share/__init__.py",
"share/pkgconfig/__init__.py",
}
headers = main_headers | conduit_headers | detail_headers | eigen_headers | stl_headers
generated_files = cmake_files | pkgconfig_files
all_files = headers | generated_files | py_files
sdist_files = {
"pyproject.toml",
"LICENSE",
"README.rst",
"PKG-INFO",
"SECURITY.md",
}
@contextlib.contextmanager
def preserve_file(filename: Path) -> Generator[str, None, None]:
old_stat = filename.stat()
old_file = filename.read_text(encoding="utf-8")
try:
yield old_file
finally:
filename.write_text(old_file, encoding="utf-8")
os.utime(filename, (old_stat.st_atime, old_stat.st_mtime))
@contextlib.contextmanager
def build_global() -> Generator[None, None, None]:
"""
Build global SDist and wheel.
"""
pyproject = MAIN_DIR / "pyproject.toml"
with preserve_file(pyproject):
newer_txt = get_global()
pyproject.write_text(newer_txt, encoding="utf-8")
yield
def read_tz_file(tar: tarfile.TarFile, name: str) -> bytes:
start = tar.getnames()[0].split("/")[0] + "/"
inner_file = tar.extractfile(tar.getmember(f"{start}{name}"))
assert inner_file
with contextlib.closing(inner_file) as f:
return f.read()
def normalize_line_endings(value: bytes) -> bytes:
return value.replace(os.linesep.encode("utf-8"), b"\n")
def test_build_sdist(monkeypatch, tmpdir):
monkeypatch.chdir(MAIN_DIR)
subprocess.run(
[sys.executable, "-m", "build", "--sdist", f"--outdir={tmpdir}", *UV_ARGS],
check=True,
)
(sdist,) = tmpdir.visit("*.tar.gz")
version = FILENAME_VERSION.search(sdist.basename).group(1)
with tarfile.open(str(sdist), "r:gz") as tar:
simpler = {n.split("/", 1)[-1] for n in tar.getnames()[1:]}
(pkg_info_path,) = (n for n in simpler if n.endswith("PKG-INFO"))
pyproject_toml = read_tz_file(tar, "pyproject.toml")
pkg_info = read_tz_file(tar, pkg_info_path).decode("utf-8")
files = headers | sdist_files
assert files <= simpler
assert b'name = "pybind11"' in pyproject_toml
assert f"Version: {version}" in pkg_info
assert "License-Expression: BSD-3-Clause" in pkg_info
assert "License-File: LICENSE" in pkg_info
assert "Provides-Extra: global" in pkg_info
assert f'Requires-Dist: pybind11-global=={version}; extra == "global"' in pkg_info
def test_build_global_dist(monkeypatch, tmpdir):
monkeypatch.chdir(MAIN_DIR)
with build_global():
subprocess.run(
[
sys.executable,
"-m",
"build",
"--sdist",
"--outdir",
str(tmpdir),
*UV_ARGS,
],
check=True,
)
(sdist,) = tmpdir.visit("*.tar.gz")
version = FILENAME_VERSION.search(sdist.basename).group(2)
with tarfile.open(str(sdist), "r:gz") as tar:
simpler = {n.split("/", 1)[-1] for n in tar.getnames()[1:]}
(pkg_info_path,) = (n for n in simpler if n.endswith("PKG-INFO"))
pyproject_toml = read_tz_file(tar, "pyproject.toml")
pkg_info = read_tz_file(tar, pkg_info_path).decode("utf-8")
files = headers | sdist_files
assert files <= simpler
assert b'name = "pybind11-global"' in pyproject_toml
assert f"Version: {version}" in pkg_info
assert "License-Expression: BSD-3-Clause" in pkg_info
assert "License-File: LICENSE" in pkg_info
assert "Provides-Extra: global" not in pkg_info
assert 'Requires-Dist: pybind11-global; extra == "global"' not in pkg_info
def tests_build_wheel(monkeypatch, tmpdir):
monkeypatch.chdir(MAIN_DIR)
subprocess.run(
[sys.executable, "-m", "build", "--wheel", "--outdir", str(tmpdir), *UV_ARGS],
check=True,
)
(wheel,) = tmpdir.visit("*.whl")
version, simple_version = FILENAME_VERSION.search(wheel.basename).groups()
files = {f"pybind11/{n}" for n in all_files}
files |= {
"dist-info/licenses/LICENSE",
"dist-info/METADATA",
"dist-info/RECORD",
"dist-info/WHEEL",
"dist-info/entry_points.txt",
}
with zipfile.ZipFile(str(wheel)) as z:
names = z.namelist()
share = zipfile.Path(z, "pybind11/share")
pkgconfig = (share / "pkgconfig/pybind11.pc").read_text(encoding="utf-8")
cmakeconfig = (share / "cmake/pybind11/pybind11Config.cmake").read_text(
encoding="utf-8"
)
(pkg_info_path,) = (n for n in names if n.endswith("METADATA"))
pkg_info = zipfile.Path(z, pkg_info_path).read_text(encoding="utf-8")
trimmed = {n for n in names if "dist-info" not in n}
trimmed |= {f"dist-info/{n.split('/', 1)[-1]}" for n in names if "dist-info" in n}
assert files == trimmed
assert 'set(pybind11_INCLUDE_DIR "${PACKAGE_PREFIX_DIR}/include")' in cmakeconfig
pkgconfig_expected = PKGCONFIG.format(VERSION=simple_version)
assert pkgconfig_expected == pkgconfig
assert f"Version: {version}" in pkg_info
assert "License-Expression: BSD-3-Clause" in pkg_info
assert "License-File: LICENSE" in pkg_info
assert "Provides-Extra: global" in pkg_info
assert f'Requires-Dist: pybind11-global=={version}; extra == "global"' in pkg_info
def tests_build_global_wheel(monkeypatch, tmpdir):
monkeypatch.chdir(MAIN_DIR)
with build_global():
subprocess.run(
[
sys.executable,
"-m",
"build",
"--wheel",
"--outdir",
str(tmpdir),
*UV_ARGS,
],
check=True,
)
(wheel,) = tmpdir.visit("*.whl")
version, simple_version = FILENAME_VERSION.search(wheel.basename).groups()
files = {f"data/data/{n}" for n in headers}
files |= {f"data/headers/{n[8:]}" for n in headers}
files |= {f"data/data/{n}" for n in generated_files}
files |= {
"dist-info/licenses/LICENSE",
"dist-info/METADATA",
"dist-info/WHEEL",
"dist-info/RECORD",
}
with zipfile.ZipFile(str(wheel)) as z:
names = z.namelist()
beginning = names[0].split("/", 1)[0].rsplit(".", 1)[0]
share = zipfile.Path(z, f"{beginning}.data/data/share")
pkgconfig = (share / "pkgconfig/pybind11.pc").read_text(encoding="utf-8")
cmakeconfig = (share / "cmake/pybind11/pybind11Config.cmake").read_text(
encoding="utf-8"
)
(pkg_info_path,) = (n for n in names if n.endswith("METADATA"))
pkg_info = zipfile.Path(z, pkg_info_path).read_text(encoding="utf-8")
assert f"Version: {version}" in pkg_info
assert "License-Expression: BSD-3-Clause" in pkg_info
assert "License-File: LICENSE" in pkg_info
assert "Provides-Extra: global" not in pkg_info
assert 'Requires-Dist: pybind11-global; extra == "global"' not in pkg_info
trimmed = {n[len(beginning) + 1 :] for n in names}
assert files == trimmed
assert 'set(pybind11_INCLUDE_DIR "${PACKAGE_PREFIX_DIR}/include")' in cmakeconfig
pkgconfig_expected = PKGCONFIG.format(VERSION=simple_version)
assert pkgconfig_expected == pkgconfig
def test_version_matches():
header = MAIN_DIR / "include/pybind11/detail/common.h"
text = header.read_text()
# Extract the relevant macro values
regex_prefix = r"#\s*define\s+PYBIND11_VERSION_"
micro = re.search(rf"{regex_prefix}MICRO\s+(\d+)\b", text).group(1)
release_level = re.search(rf"{regex_prefix}RELEASE_LEVEL\s+(\w+)\b", text).group(1)
release_serial = re.search(
rf"{regex_prefix}RELEASE_SERIAL\s+(\d+)\b",
text,
).group(1)
patch = re.search(rf"{regex_prefix}PATCH\s+([\w.-]+)\b", text).group(1)
# Map release level macro to string
level_map = {
"PY_RELEASE_LEVEL_ALPHA": "a",
"PY_RELEASE_LEVEL_BETA": "b",
"PY_RELEASE_LEVEL_GAMMA": "rc",
"PY_RELEASE_LEVEL_FINAL": "",
}
level_str = level_map[release_level]
if release_level == "PY_RELEASE_LEVEL_FINAL":
assert level_str == ""
assert release_serial == "0"
expected_patch = micro
else:
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")