Add new test that monitors symbols required from dynamic compiled libs

This commit is contained in:
Lumir Balhar 2026-06-24 22:05:28 +02:00
commit b4af9ca880
3 changed files with 170 additions and 0 deletions

73
required-symbols/check.sh Executable file
View file

@ -0,0 +1,73 @@
#!/bin/bash
# Compare required external symbols of compiled Python extension modules between
# the PR build (pre-installed by Testing Farm) and the latest stable version
# available in the distribution repositories.
#
# The PR build is already installed before this test runs (via the Testing Farm
# artifact mechanism). We save its .so files, downgrade to the stable repo version,
# collect symbols from both, and compare.
#
# Output:
# + [module] symbol — PR build adds a new required symbol (potential issue possibly requiring update of Requires)
# - [module] symbol — PR build drops a previously required symbol (generally OK)
#
# Exit codes:
# 0 — no changes, or no stable version available to compare against (skipped)
# 1 — symbol differences detected between PR build and stable release
set -eo pipefail
PYVER=${VERSION}
SCRIPT_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")"
# Directories containing compiled extension modules
SCAN_DIRS=()
for d in "/usr/lib64/python${PYVER}" "/usr/lib64/python${PYVER}t"; do
[ -d "$d" ] && SCAN_DIRS+=("$d")
done
if [ ${#SCAN_DIRS[@]} -eq 0 ]; then
echo "ERROR: No Python extension directories found for python${PYVER}" >&2
exit 1
fi
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
echo "=== Collecting PR build symbols ==="
python${PYVER} "$SCRIPT_DIR/collect_symbols.py" "${SCAN_DIRS[@]}" > "$WORK_DIR/new.json"
# Downgrade to the latest stable version in the distribution repos.
# If no older version is available, there is nothing to compare against → skip.
echo ""
echo "=== Available repos ==="
dnf repolist --all
echo ""
echo "=== Downgrading to the latest stable version in repos ==="
# Discover packages dynamically from the installed source RPM so we don't need
# to hardcode subpackage names — works regardless of which subpackages were built.
readarray -t PKGS < <(
rpm -qa --qf '%{SOURCERPM} %{NAME}\n' |
awk -v src="python${PYVER}" '$1 ~ "^" src "-[0-9]" { print $2 }' |
sort -u
)
echo "Packages to downgrade: ${PKGS[*]}"
if ! dnf downgrade -y "${PKGS[@]}" 2>&1; then
echo "INFO: dnf downgrade failed or no stable version available; skipping comparison." >&2
exit 0
fi
echo ""
echo "=== Collecting stable version symbols ==="
python${PYVER} "$SCRIPT_DIR/collect_symbols.py" "${SCAN_DIRS[@]}" > "$WORK_DIR/old.json"
echo ""
echo "=== Comparing PR build against stable ==="
python${PYVER} "$SCRIPT_DIR/compare_symbols.py" "$WORK_DIR/old.json" "$WORK_DIR/new.json" || COMPARE_RC=$?
echo ""
echo "=== Reverting package downgrade ==="
dnf history undo last -y 2>&1
exit ${COMPARE_RC:-0}

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
Collect undefined non-boring external symbols from compiled Python extension modules.
Usage: collect_symbols.py <directory> [<directory>...]
Scans all *.cpython-*.so files found recursively, unions symbols across all
variants of each module (regular/debug/freethreading), and prints JSON to stdout.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
BORING = re.compile(
r"@GLIBC_" # glibc versioned symbols (stable by definition)
r"|@GCC_" # GCC built-ins
r"|^_?Py[A-Za-z_]" # Python C API (resolved from libpython at runtime)
r"|^__" # C runtime internals
r"|^_ITM_" # Intel transactional memory
)
def module_name(so: Path) -> str:
# Strip ABI tag (d=debug, t=freethreading, td=both) so variants of the same
# module merge together: _ssl.cpython-314td-x86_64-linux-gnu.so → _ssl
return re.sub(r"\.cpython-\d+[a-z]*-[^.]+\.so$", "", so.name)
def external_symbols(so: Path) -> list[str]:
result = subprocess.run(["nm", "-D", str(so)], capture_output=True, text=True)
return sorted(
parts[-1]
for line in result.stdout.splitlines()
if len(parts := line.split()) >= 2
and parts[-2] == "U"
and not BORING.search(parts[-1])
)
modules: dict[str, set[str]] = {}
for path_arg in sys.argv[1:]:
print(f"Scanning: {path_arg}", file=sys.stderr)
for so in sorted(Path(path_arg).rglob("*.cpython-*.so")):
if so.is_symlink():
continue
if syms := external_symbols(so):
modules.setdefault(module_name(so), set()).update(syms)
total_symbols = sum(len(v) for v in modules.values())
print(f"Found {len(modules)} modules, {total_symbols} tracked symbols", file=sys.stderr)
json.dump({k: sorted(v) for k, v in sorted(modules.items())}, sys.stdout, indent=2)
print()

View file

@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""
Compare two symbol JSON files produced by collect_symbols.py.
Usage: compare_symbols.py <old.json> <new.json>
Output:
+ [module] symbol new build adds a required symbol the old build did not have
- [module] symbol new build drops a required symbol the old build had
Exit codes:
0 no differences
1 differences found
"""
import json
import sys
from pathlib import Path
old = json.loads(Path(sys.argv[1]).read_text())
new = json.loads(Path(sys.argv[2]).read_text())
all_mods = sorted(set(old) | set(new))
changes = []
for mod in all_mods:
added = sorted(set(new.get(mod, [])) - set(old.get(mod, [])))
removed = sorted(set(old.get(mod, [])) - set(new.get(mod, [])))
if added or removed:
changes.append((mod, added, removed))
if not changes:
print("OK: no symbol changes between stable and PR build")
sys.exit(0)
print("Symbol changes detected (+ new requirement in PR build, - dropped by PR build):")
for mod, added, removed in changes:
for sym in added:
print(f" + [{mod}] {sym}")
for sym in removed:
print(f" - [{mod}] {sym}")
sys.exit(1)