21601: Build Python distro packages from wheels
[arvados.git] / build / pypkg_info.py
diff --git a/build/pypkg_info.py b/build/pypkg_info.py
new file mode 100644 (file)
index 0000000..45f8d16
--- /dev/null
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+"""pypkg_info.py - Introspect installed Python packages
+
+This tool can read metadata about any Python package installed in the current
+environment and report it out in various formats. We use this mainly to pass
+information through when building distribution packages.
+"""
+
+import argparse
+import enum
+import importlib.metadata
+import os
+import sys
+
+from pathlib import PurePath
+
+class RawFormat:
+    def format_metadata(self, key, value):
+        return value
+
+    def format_path(self, path):
+        return str(path)
+
+
+class FPMFormat(RawFormat):
+    PYTHON_METADATA_MAP = {
+        'summary': 'description',
+    }
+
+    def format_metadata(self, key, value):
+        key = key.lower()
+        key = self.PYTHON_METADATA_MAP.get(key, key)
+        return f'--{key}={value}'
+
+
+class Formats(enum.Enum):
+    RAW = RawFormat
+    FPM = FPMFormat
+
+    @classmethod
+    def from_arg(cls, arg):
+        try:
+            return cls[arg.upper()]
+        except KeyError:
+            raise ValueError(f"unknown format {arg!r}") from None
+
+
+def report_binfiles(args):
+    bin_names = [
+        PurePath('bin', path.name)
+        for pkg_name in args.package_names
+        for path in importlib.metadata.distribution(pkg_name).files
+        if path.parts[-3:-1] == ('..', 'bin')
+    ]
+    fmt = args.format.value().format_path
+    return (fmt(path) for path in bin_names)
+
+def report_metadata(args):
+    dist = importlib.metadata.distribution(args.package_name)
+    fmt = args.format.value().format_metadata
+    for key in args.metadata_key:
+        yield fmt(key, dist.metadata.get(key, ''))
+
+def unescape_str(arg):
+    arg = arg.replace('\'', '\\\'')
+    return eval(f"'''{arg}'''", {})
+
+def parse_arguments(arglist=None):
+    parser = argparse.ArgumentParser()
+    parser.set_defaults(action=None)
+    format_names = ', '.join(fmt.name.lower() for fmt in Formats)
+    parser.add_argument(
+        '--format', '-f',
+        choices=list(Formats),
+        default=Formats.RAW,
+        type=Formats.from_arg,
+        help=f"Output format. Choices are: {format_names}",
+    )
+    parser.add_argument(
+        '--delimiter', '-d',
+        default='\n',
+        type=unescape_str,
+        help="Line ending. Python backslash escapes are supported. Default newline.",
+    )
+    subparsers = parser.add_subparsers()
+
+    binfiles = subparsers.add_parser('binfiles')
+    binfiles.set_defaults(action=report_binfiles)
+    binfiles.add_argument(
+        'package_names',
+        nargs=argparse.ONE_OR_MORE,
+    )
+
+    metadata = subparsers.add_parser('metadata')
+    metadata.set_defaults(action=report_metadata)
+    metadata.add_argument(
+        'package_name',
+    )
+    metadata.add_argument(
+        'metadata_key',
+        nargs=argparse.ONE_OR_MORE,
+    )
+
+    args = parser.parse_args()
+    if args.action is None:
+        parser.error("subcommand is required")
+    return args
+
+def main(arglist=None):
+    args = parse_arguments(arglist)
+    try:
+        for line in args.action(args):
+            print(line, end=args.delimiter)
+    except importlib.metadata.PackageNotFoundError as error:
+        print(f"error: package not found: {error.args[0]}", file=sys.stderr)
+        return os.EX_NOTFOUND
+    else:
+        return os.EX_OK
+
+if __name__ == '__main__':
+    exit(main())