Add new test that monitors symbols required from dynamic compiled libs
This commit is contained in:
parent
dfd41a9acf
commit
b4af9ca880
3 changed files with 170 additions and 0 deletions
73
required-symbols/check.sh
Executable file
73
required-symbols/check.sh
Executable 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}
|
||||
56
required-symbols/collect_symbols.py
Executable file
56
required-symbols/collect_symbols.py
Executable 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()
|
||||
41
required-symbols/compare_symbols.py
Executable file
41
required-symbols/compare_symbols.py
Executable 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue