#!/usr/bin/env python """Check exported symbols Check that all symbols exported by CPython (libpython, stdlib extension modules, and similar) start with Py or _Py, or are covered by an exception. """ import argparse import dataclasses import functools import pathlib import subprocess import sys import sysconfig ALLOWED_PREFIXES = ('Py', '_Py') if sys.platform == 'darwin': ALLOWED_PREFIXES += ('__Py',) # mimalloc doesn't use static, but it's symbols are not exported # from the shared library. They do show up in the static library # before its linked into an executable. ALLOWED_STATIC_PREFIXES = ('mi_', '_mi_') # "Legacy": some old symbols are prefixed by "PY_". EXCEPTIONS = frozenset({ 'PY_TIMEOUT_MAX', }) IGNORED_EXTENSION = "_ctypes_test" @dataclasses.dataclass class Library: path: pathlib.Path is_dynamic: bool @functools.cached_property def is_ignored(self): name_without_extemnsions = self.path.name.partition('.')[0] return name_without_extemnsions == IGNORED_EXTENSION @dataclasses.dataclass class Symbol: name: str type: str library: str def __str__(self): return f"{self.name!r} (type {self.type}) from {self.library.path}" @functools.cached_property def is_local(self): # If lowercase, the symbol is usually local; if uppercase, the symbol # is global (external). There are however a few lowercase symbols that # are shown for special global symbols ("u", "v" and "w"). if self.type.islower() and self.type not in "uvw": return True return False @functools.cached_property def is_smelly(self): if self.is_local: return False if self.name.startswith(ALLOWED_PREFIXES): return False if self.name in EXCEPTIONS: return False if not self.library.is_dynamic and self.name.startswith( ALLOWED_STATIC_PREFIXES): return False if self.library.is_ignored: return False return True @functools.cached_property def _sort_key(self): return self.name, self.library.path def __lt__(self, other_symbol): return self._sort_key < other_symbol._sort_key def get_exported_symbols(library): # Only look at dynamic symbols args = ['nm', '--no-sort'] if library.is_dynamic: args.append('--dynamic') args.append(library.path) proc = subprocess.run(args, stdout=subprocess.PIPE, encoding='utf-8') if proc.returncode: print("+", args) sys.stdout.write(proc.stdout) sys.exit(proc.returncode) stdout = proc.stdout.rstrip() if not stdout: raise Exception("command output is empty") symbols = [] for line in stdout.splitlines(): if not line: continue # Split lines like '0000000000001b80 D PyTextIOWrapper_Type' parts = line.split(maxsplit=2) # Ignore lines like ' U PyDict_SetItemString' # and headers like 'pystrtod.o:' if len(parts) < 3: continue symbol = Symbol(name=parts[-1], type=parts[1], library=library) if not symbol.is_local: symbols.append(symbol) return symbols def get_extension_libraries(): # This assumes pybuilddir.txt is in same directory as pyconfig.h. # In the case of out-of-tree builds, we can't assume pybuilddir.txt is # in the source folder. config_dir = pathlib.Path(sysconfig.get_config_h_filename()).parent try: config_dir = config_dir.relative_to(pathlib.Path.cwd(), walk_up=True) except ValueError: pass filename = config_dir / "pybuilddir.txt" pybuilddir = filename.read_text().strip() builddir = config_dir / pybuilddir result = [] for path in sorted(builddir.glob('**/*.so')): if path.stem == IGNORED_EXTENSION: continue result.append(Library(path, is_dynamic=True)) return result def main(): parser = argparse.ArgumentParser( description=__doc__.split('\n', 1)[-1]) parser.add_argument('-v', '--verbose', action='store_true', help='be verbose (currently: print out all symbols)') args = parser.parse_args() libraries = [] # static library try: LIBRARY = pathlib.Path(sysconfig.get_config_var('LIBRARY')) except TypeError as exc: raise Exception("failed to get LIBRARY sysconfig variable") from exc LIBRARY = pathlib.Path(LIBRARY) if LIBRARY.exists(): libraries.append(Library(LIBRARY, is_dynamic=False)) # dynamic library try: LDLIBRARY = pathlib.Path(sysconfig.get_config_var('LDLIBRARY')) except TypeError as exc: raise Exception("failed to get LDLIBRARY sysconfig variable") from exc if LDLIBRARY != LIBRARY: libraries.append(Library(LDLIBRARY, is_dynamic=True)) # Check extension modules like _ssl.cpython-310d-x86_64-linux-gnu.so libraries.extend(get_extension_libraries()) smelly_symbols = [] for library in libraries: symbols = get_exported_symbols(library) if args.verbose: print(f"{library.path}: {len(symbols)} symbol(s) found") for symbol in sorted(symbols): if args.verbose: print(" -", symbol.name) if symbol.is_smelly: smelly_symbols.append(symbol) print() if smelly_symbols: print(f"Found {len(smelly_symbols)} smelly symbols in total!") for symbol in sorted(smelly_symbols): print(f" - {symbol.name} from {symbol.library.path}") sys.exit(1) print(f"OK: all exported symbols of all libraries", f"are prefixed with {' or '.join(map(repr, ALLOWED_PREFIXES))}", f"or are covered by exceptions") if __name__ == "__main__": main()