aboutsummaryrefslogtreecommitdiffstats
path: root/check_abi
diff options
context:
space:
mode:
authorJay Berkenbilt <ejb@ql.org>2022-03-15 02:39:29 +0100
committerJay Berkenbilt <jberkenbilt@users.noreply.github.com>2022-03-19 00:53:18 +0100
commitacdf5b2e7a9b3074125bc95bfcf7e6abdc9704b4 (patch)
tree2606c1709893f0011837fe56bdc2ba3337e5440e /check_abi
parent4c0addfe660fcda049ab1d79d337871c1df798f7 (diff)
downloadqpdf-acdf5b2e7a9b3074125bc95bfcf7e6abdc9704b4.tar.zst
Update process for ABI testing
Diffstat (limited to 'check_abi')
-rwxr-xr-xcheck_abi213
1 files changed, 213 insertions, 0 deletions
diff --git a/check_abi b/check_abi
new file mode 100755
index 00000000..486b2c10
--- /dev/null
+++ b/check_abi
@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+import os
+import sys
+import argparse
+import subprocess
+import re
+
+whoami = os.path.basename(sys.argv[0])
+whereami = os.path.dirname(os.path.realpath(__file__))
+
+
+def warn(*args, **kwargs):
+ print(*args, file=sys.stderr, **kwargs)
+
+
+class Main:
+ def main(self, args=sys.argv[1:], prog=whoami):
+ options = self.parse_args(args, prog)
+ if options.action == 'dump':
+ self.dump(options)
+ elif options.action == 'check-sizes':
+ self.check_sizes(options)
+ elif options.action == 'compare':
+ self.compare(options)
+ else:
+ exit(f'{whoami}: unknown action')
+
+ def parse_args(self, args, prog):
+ parser = argparse.ArgumentParser(
+ prog=prog,
+ # formatter_class=argparse.RawDescriptionHelpFormatter,
+ description='Check ABI for changes',
+ )
+
+ subparsers = parser.add_subparsers(
+ dest='action',
+ help='specify subcommand; run action with --help for details',
+ required=True)
+ lib_arg = ('--lib', {'help': 'library file', 'required': True})
+
+ p_dump = subparsers.add_parser(
+ 'dump',
+ help='dump qpdf symbols in a library')
+ p_dump.add_argument(lib_arg[0], **lib_arg[1])
+
+ p_check_sizes = subparsers.add_parser(
+ 'check-sizes',
+ help='check consistency between library and sizes.cc')
+ p_check_sizes.add_argument(lib_arg[0], **lib_arg[1])
+
+ p_compare = subparsers.add_parser(
+ 'compare',
+ help='compare libraries and sizes')
+ p_compare.add_argument('--new-lib',
+ help='new library file',
+ required=True)
+ p_compare.add_argument('--old-lib',
+ help='old library file',
+ required=True)
+ p_compare.add_argument('--old-sizes',
+ help='output of old sizes',
+ required=True)
+ p_compare.add_argument('--new-sizes',
+ help='output of new sizes',
+ required=True)
+ return parser.parse_args(args)
+
+ def get_versions(self, path):
+ p = os.path.basename(os.path.realpath(path))
+ m = re.match(r'^libqpdf.so.(\d+).(\d+).(\d+)$', p)
+ if not m:
+ exit(f'{whoami}: {path} does end with libqpdf.so.x.y.z')
+ major = int(m.group(1))
+ minor = int(m.group(2))
+ patch = int(m.group(3))
+ return (major, minor, patch)
+
+ def get_symbols(self, path):
+ symbols = set()
+ p = subprocess.run(
+ ['nm', '-D', '--demangle', '--with-symbol-versions', path],
+ stdout=subprocess.PIPE)
+ if p.returncode:
+ exit(f'{whoami}: failed to get symbols from {path}')
+ for line in p.stdout.decode().split('\n'):
+ # The LIBQPDF_\d+ comes from the version tag in
+ # libqpdf.map.in.
+ m = re.match(r'^[0-9a-f]+ (.) (.+)@@LIBQPDF_\d+\s*$', line)
+ if not m:
+ continue
+ symbols.add(m.group(2))
+ return symbols
+
+ def dump(self, options):
+ # This is just for manual use to investigate surprises.
+ for i in sorted(self.get_symbols(options.lib)):
+ print(i)
+
+ def check_sizes(self, options):
+ # Make sure that every class with methods in the public API
+ # appears in sizes.cc either explicitly ignored or in a
+ # print_size call. This enables us to reliably test whether
+ # any object changed size by following the ABI checking
+ # procedures outlined in README-maintainer.
+
+ # To keep things up to date, whenever we add or remove
+ # objects, we have to update sizes.cc. The check-sizes option
+ # can be run at any time on an up-to-date build.
+
+ lib = self.get_symbols(options.lib)
+ classes = set()
+ for i in sorted(lib):
+ # Find a symbol that looks like a class method.
+ m = re.match(r'(((?:^\S*?::)?(?:[^:\s]+))::([^:\s]+))\(', i)
+ if m:
+ full = m.group(1)
+ clas = m.group(2)
+ method = m.group(3)
+ if full.startswith('std::') or method.startswith('~'):
+ # Sometimes std:: template instantiations make it
+ # into the library. Ignore those. Also ignore
+ # classes whose only exported method is a
+ # destructor.
+ continue
+ # Otherwise, if the class exports a method, we
+ # potentially care about changes to its size, so add
+ # it.
+ classes.add(clas)
+ in_sizes = set()
+ # Read the sizes.cc to make sure everything's there.
+ with open(os.path.join(whereami, 'qpdf/sizes.cc'), 'r') as f:
+ for line in f.readlines():
+ m = re.search(r'^\s*(?:ignore_class|print_size)\((.*?)\)',
+ line)
+ if m:
+ in_sizes.add(m.group(1))
+ sizes_only = in_sizes - classes
+ classes_only = classes - in_sizes
+ if sizes_only or classes_only:
+ if sizes_only:
+ print("classes in sizes.cc but not in the library:")
+ for i in sorted(sizes_only):
+ print(' ', i)
+ if classes_only:
+ print("classes in the library but not in sizes.cc:")
+ for i in sorted(classes_only):
+ print(' ', i)
+ exit(f'{whoami}: mismatch between library and sizes.cc')
+ else:
+ print(f'{whoami}: sizes.cc is consistent with the library')
+
+ def read_sizes(self, filename):
+ sizes = {}
+ with open(filename, 'r') as f:
+ for line in f.readlines():
+ line = line.strip()
+ m = re.match(r'^(.*) (\d+)$', line)
+ if not m:
+ exit(f'{filename}: bad sizes line: {line}')
+ sizes[m.group(1)] = m.group(2)
+ return sizes
+
+ def compare(self, options):
+ old_version = self.get_versions(options.old_lib)
+ new_version = self.get_versions(options.new_lib)
+ old = self.get_symbols(options.old_lib)
+ new = self.get_symbols(options.new_lib)
+ if old_version > new_version:
+ exit(f'{whoami}: old version is newer than new version')
+ allow_abi_change = new_version[0] > old_version[0]
+ allow_added = allow_abi_change or (new_version[1] > old_version[1])
+ removed = sorted(old - new)
+ added = sorted(new - old)
+ if removed:
+ print('INTERFACES REMOVED:')
+ for i in removed:
+ print(' ', i)
+ else:
+ print('No interfaces were removed')
+ if added:
+ print('INTERFACES ADDED')
+ for i in added:
+ print(' ', i)
+ else:
+ print('No interfaces were added')
+
+ if removed and not allow_abi_change:
+ exit(f'{whoami}: **ERROR**: major version must be bumped')
+ elif added and not allow_added:
+ exit(f'{whoami}: **ERROR**: minor version must be bumped')
+ else:
+ print(f'{whoami}: ABI check passed.')
+
+ old_sizes = self.read_sizes(options.old_sizes)
+ new_sizes = self.read_sizes(options.new_sizes)
+ size_errors = False
+ for k, v in old_sizes.items():
+ if k in new_sizes and v != new_sizes[k]:
+ size_errors = True
+ print(f'{k} changed size from {v} to {new_sizes[k]}')
+ if size_errors:
+ if not allow_abi_change:
+ exit(f'{whoami}:'
+ 'size changes detected; this is an ABI change.')
+ else:
+ print(f'{whoami}: no size changes detected')
+
+
+if __name__ == '__main__':
+ try:
+ Main().main()
+ except KeyboardInterrupt:
+ exit(130)