21700: Install Bundler system-wide in Rails postinst
[arvados.git] / build / pypkg_info.py
1 #!/usr/bin/env python3
2 # Copyright (C) The Arvados Authors. All rights reserved.
3 #
4 # SPDX-License-Identifier: AGPL-3.0
5 """pypkg_info.py - Introspect installed Python packages
6
7 This tool can read metadata about any Python package installed in the current
8 environment and report it out in various formats. We use this mainly to pass
9 information through when building distribution packages.
10 """
11
12 import argparse
13 import enum
14 import importlib.metadata
15 import os
16 import sys
17
18 from pathlib import PurePath
19
20 class RawFormat:
21     def format_metadata(self, key, value):
22         return value
23
24     def format_path(self, path):
25         return str(path)
26
27
28 class FPMFormat(RawFormat):
29     PYTHON_METADATA_MAP = {
30         'summary': 'description',
31     }
32
33     def format_metadata(self, key, value):
34         key = key.lower()
35         key = self.PYTHON_METADATA_MAP.get(key, key)
36         return f'--{key}={value}'
37
38
39 class Formats(enum.Enum):
40     RAW = RawFormat
41     FPM = FPMFormat
42
43     @classmethod
44     def from_arg(cls, arg):
45         try:
46             return cls[arg.upper()]
47         except KeyError:
48             raise ValueError(f"unknown format {arg!r}") from None
49
50
51 def report_binfiles(args):
52     bin_names = [
53         PurePath('bin', path.name)
54         for pkg_name in args.package_names
55         for path in importlib.metadata.distribution(pkg_name).files
56         if path.parts[-3:-1] == ('..', 'bin')
57     ]
58     fmt = args.format.value().format_path
59     return (fmt(path) for path in bin_names)
60
61 def report_metadata(args):
62     dist = importlib.metadata.distribution(args.package_name)
63     fmt = args.format.value().format_metadata
64     for key in args.metadata_key:
65         yield fmt(key, dist.metadata.get(key, ''))
66
67 def unescape_str(arg):
68     arg = arg.replace('\'', '\\\'')
69     return eval(f"'''{arg}'''", {})
70
71 def parse_arguments(arglist=None):
72     parser = argparse.ArgumentParser()
73     parser.set_defaults(action=None)
74     format_names = ', '.join(fmt.name.lower() for fmt in Formats)
75     parser.add_argument(
76         '--format', '-f',
77         choices=list(Formats),
78         default=Formats.RAW,
79         type=Formats.from_arg,
80         help=f"Output format. Choices are: {format_names}",
81     )
82     parser.add_argument(
83         '--delimiter', '-d',
84         default='\n',
85         type=unescape_str,
86         help="Line ending. Python backslash escapes are supported. Default newline.",
87     )
88     subparsers = parser.add_subparsers()
89
90     binfiles = subparsers.add_parser('binfiles')
91     binfiles.set_defaults(action=report_binfiles)
92     binfiles.add_argument(
93         'package_names',
94         nargs=argparse.ONE_OR_MORE,
95     )
96
97     metadata = subparsers.add_parser('metadata')
98     metadata.set_defaults(action=report_metadata)
99     metadata.add_argument(
100         'package_name',
101     )
102     metadata.add_argument(
103         'metadata_key',
104         nargs=argparse.ONE_OR_MORE,
105     )
106
107     args = parser.parse_args()
108     if args.action is None:
109         parser.error("subcommand is required")
110     return args
111
112 def main(arglist=None):
113     args = parse_arguments(arglist)
114     try:
115         for line in args.action(args):
116             print(line, end=args.delimiter)
117     except importlib.metadata.PackageNotFoundError as error:
118         print(f"error: package not found: {error.args[0]}", file=sys.stderr)
119         return os.EX_NOTFOUND
120     else:
121         return os.EX_OK
122
123 if __name__ == '__main__':
124     exit(main())