chore: lint the -inl.h invariants of the precompiled mode tools/check_inl_headers.py checks that each namespace-scope definition in an -inl.h file has PYBIND11_INLINE, and that each macro in a preprocessor condition is known: either the same for the library and the module, or encoded in the precompiled config guard. Also document that a custom PYBIND11_NAMESPACE must reach the library. Assisted-by: ClaudeCode:claude-opus-5-5
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6251152..d61e642 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml
@@ -141,6 +141,11 @@ language: pygrep entry: PyBind|\bNumpy\b|Cmake|CCache|PyTest exclude: ^\.pre-commit-config.yaml$ + - id: check-inl-headers + name: Check -inl.h files for the precompiled mode + language: python + entry: python tools/check_inl_headers.py + files: -inl\.h$ # PyLint has native support - not always usable, but works for us - repo: https://github.com/PyCQA/pylint
diff --git a/docs/compiling.rst b/docs/compiling.rst index a9c9fcf..42cd320 100644 --- a/docs/compiling.rst +++ b/docs/compiling.rst
@@ -441,7 +441,10 @@ ``pybind11_precompiled_config``. * Configuration macros that only change code inside the library (for example ``PYBIND11_DISABLE_NEW_STYLE_INIT_WARNING``) must be defined when the - library is compiled; a definition only on your module has no effect. + library is compiled; a definition only on your module has no effect. This + also applies to a custom ``PYBIND11_NAMESPACE`` visibility attribute: set + it at the directory level (``add_compile_definitions``) so that the library + gets it too. * The library picks up your directory-level flags and C++ standard when it is first created, so set those before the first ``PRECOMPILE`` target. A status message reports the directory that created the library.
diff --git a/include/pybind11/detail/internals.h b/include/pybind11/detail/internals.h index 224198c..164da39 100644 --- a/include/pybind11/detail/internals.h +++ b/include/pybind11/detail/internals.h
@@ -52,7 +52,8 @@ // the library and the modules linking it. PYBIND11_MODULE calls it, so a mismatch (or a // missing library) surfaces as one readable undefined symbol at link time instead of many // unrelated ones at run time. If you add a configuration macro that changes the code in the -// -inl.h files, encode it here and add it to the list in docs/compiling.rst. +// -inl.h files, encode it here, add it to the list in docs/compiling.rst, and add it to +// tools/check_inl_headers.py. # if defined(Py_GIL_DISABLED) # define PYBIND11_PRECOMPILED_CFG_GD 1 # else
diff --git a/tools/check_inl_headers.py b/tools/check_inl_headers.py new file mode 100755 index 0000000..6787fb7 --- /dev/null +++ b/tools/check_inl_headers.py
@@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Check the invariants of the -inl.h files used by the precompiled mode: + +* Every namespace-scope function definition is marked PYBIND11_INLINE. +* Every macro in a preprocessor condition is known. A macro that changes the + code must either be the same for the library and the modules (platform, + compiler, Python version), or be encoded in PYBIND11_PRECOMPILED_CONFIG_CHECK + (detail/internals.h) and listed in docs/compiling.rst. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# Same in the library and in the modules that link it. +ENVIRONMENT_MACROS = { + "GRAALVM_PYTHON", + "NDEBUG", + "PYBIND11_BUILTIN_QUALNAME", + "PYBIND11_HAS_STRING_VIEW", + "PYBIND11_PRECOMPILED", + "PYPY_VERSION", + "PY_MAJOR_VERSION", + "PY_MINOR_VERSION", + "PY_VERSION_HEX", + "Py_REF_DEBUG", + "_MSC_VER", + "_WIN32", + "__GLIBCXX__", + "__GNUC__", + "__cpp_lib_unordered_map_try_emplace", + "__clang__", +} + +# Encoded in PYBIND11_PRECOMPILED_CONFIG_CHECK. +GUARDED_MACROS = { + "PYBIND11_BACKWARD_COMPATIBILITY_TP_DICTOFFSET", + "PYBIND11_DETAILED_ERROR_MESSAGES", + "PYBIND11_HAS_SUBINTERPRETER_SUPPORT", + "PYBIND11_INTERNALS_VERSION", + "PYBIND11_SIMPLE_GIL_MANAGEMENT", + "Py_GIL_DISABLED", +} + +# Only change code inside the library; documented in docs/compiling.rst. +LIBRARY_ONLY_MACROS = { + "PYBIND11_DISABLE_NEW_STYLE_INIT_WARNING", +} + +KNOWN_MACROS = ENVIRONMENT_MACROS | GUARDED_MACROS | LIBRARY_ONLY_MACROS + +TOKENS = re.compile( + r""" + (?P<raw>R"(?P<delim>[^(\s]*)\(.*?\)(?P=delim)") + | (?P<str>"(?:\\.|[^"\\\n])*") + | (?P<chr>'(?:\\.|[^'\\\n])*') + | (?P<line_comment>//[^\n]*) + | (?P<block_comment>/\*.*?\*/) + """, + re.VERBOSE | re.DOTALL, +) +PP_CONDITION = re.compile(r"^[ \t]*#\s*(?:if|elif|ifdef|ifndef)\b(.*)$", re.MULTILINE) +PP_LINE = re.compile(r"^[ \t]*#.*$", re.MULTILINE) +NAMESPACE_MACRO = re.compile( + r"^[ \t]*PYBIND11_(?:NAMESPACE_BEGIN|NAMESPACE_END|WARNING_\w+)\(.*\)[ \t]*$", + re.MULTILINE, +) +IDENTIFIER = re.compile(r"\b[A-Za-z_]\w*\b") +NON_FUNCTION_BLOCK = re.compile(r"^(?:struct|class|union|enum)\b|=$") + + +def strip(text: str) -> str: + """Blank out comments and string literals, keeping the line numbers.""" + + def blank(match: re.Match[str]) -> str: + if match.group("line_comment") or match.group("block_comment"): + return re.sub(r"[^\n]", " ", match.group()) + return '""' + "\n" * match.group().count("\n") + + return TOKENS.sub(blank, text) + + +def check(path: Path) -> list[str]: + text = strip(path.read_text(encoding="utf-8")) + errors: list[str] = [] + + for match in PP_CONDITION.finditer(text): + line = text.count("\n", 0, match.start()) + 1 + errors.extend( + f"{path}:{line}: unknown configuration macro {name}; see {Path(__file__).name}" + for name in IDENTIFIER.findall(match.group(1)) + if name != "defined" and name not in KNOWN_MACROS + ) + + code = PP_LINE.sub(lambda m: " " * len(m.group()), text) + code = NAMESPACE_MACRO.sub(lambda m: " " * len(m.group()), code) + + # One entry per open brace: True for namespace and extern "C" blocks, whose + # contents are still at namespace scope. + scopes: list[bool] = [] + start = 0 + for pos, char in enumerate(code): + at_namespace_scope = all(scopes) + if char == "{": + if not at_namespace_scope: + scopes.append(False) + continue + header = " ".join(code[start:pos].split()) + transparent = header == 'extern "C"' or header.startswith("namespace") + if ( + not transparent + and "(" in header + and not NON_FUNCTION_BLOCK.search(header) + and "PYBIND11_INLINE" not in header.split() + ): + offset = start + len(code[start:pos]) - len(code[start:pos].lstrip()) + line = code.count("\n", 0, offset) + 1 + errors.append(f"{path}:{line}: definition without PYBIND11_INLINE") + scopes.append(transparent) + if transparent: + start = pos + 1 + elif char == "}": + scopes.pop() + if all(scopes): + start = pos + 1 + elif char == ";" and at_namespace_scope: + start = pos + 1 + + return errors + + +def main(argv: list[str]) -> int: + errors = [error for arg in argv for error in check(Path(arg))] + for error in errors: + print(error) + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))