diff --git a/required-symbols/check.sh b/required-symbols/check.sh new file mode 100755 index 0000000..afce753 --- /dev/null +++ b/required-symbols/check.sh @@ -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} diff --git a/required-symbols/collect_symbols.py b/required-symbols/collect_symbols.py new file mode 100755 index 0000000..9550a39 --- /dev/null +++ b/required-symbols/collect_symbols.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +Collect undefined non-boring external symbols from compiled Python extension modules. + +Usage: collect_symbols.py [...] + +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() diff --git a/required-symbols/compare_symbols.py b/required-symbols/compare_symbols.py new file mode 100755 index 0000000..2692993 --- /dev/null +++ b/required-symbols/compare_symbols.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +""" +Compare two symbol JSON files produced by collect_symbols.py. + +Usage: compare_symbols.py + +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)