1#!/usr/bin/env python3
2
3import enum
4import glob
5import multiprocessing
6import os
7import subprocess
8import sys
9import tarfile
10
11@enum.unique
12class Host(enum.Enum):
13    """Enumeration of supported hosts."""
14    Darwin = 'darwin'
15    Linux = 'linux'
16
17
18def get_default_host():
19    """Returns the Host matching the current machine."""
20    if sys.platform.startswith('linux'):
21        return Host.Linux
22    elif sys.platform.startswith('darwin'):
23        return Host.Darwin
24    else:
25        raise RuntimeError('Unsupported host: {}'.format(sys.platform))
26
27
28def build_autoconf_target(host, python_src, build_dir, install_dir,
29                          extra_ldflags):
30    print('## Building Python ##')
31    print('## Build Dir   : {}'.format(build_dir))
32    print('## Install Dir : {}'.format(install_dir))
33    print('## Python Src  : {}'.format(python_src))
34    sys.stdout.flush()
35
36    os.makedirs(build_dir, exist_ok=True)
37    os.makedirs(install_dir, exist_ok=True)
38
39    cflags = ['-Wno-unused-command-line-argument']
40    ldflags = ['-s']
41    config_cmd = [
42        os.path.join(python_src, 'configure'),
43        '--prefix={}'.format(install_dir),
44        '--enable-shared',
45        '--with-ensurepip=install',
46    ]
47    env = dict(os.environ)
48    if host == Host.Darwin:
49        sdkroot = env.get('SDKROOT')
50        if sdkroot:
51            print("Using SDK {}".format(sdkroot))
52            config_cmd.append('--enable-universalsdk={}'.format(sdkroot))
53        else:
54            config_cmd.append('--enable-universalsdk')
55        config_cmd.append('--with-universal-archs=universal2')
56
57        MAC_MIN_VERSION = '10.14'
58        cflags.append('-mmacosx-version-min={}'.format(MAC_MIN_VERSION))
59        cflags.append('-DMACOSX_DEPLOYMENT_TARGET={}'.format(MAC_MIN_VERSION))
60        cflags.extend(['-arch', 'arm64'])
61        cflags.extend(['-arch', 'x86_64'])
62        env['MACOSX_DEPLOYMENT_TARGET'] = MAC_MIN_VERSION
63        ldflags.append("-Wl,-rpath,'@loader_path/../lib'")
64
65        # Disable functions to support old macOS. See https://bugs.python.org/issue31359
66        # Fails the build if any new API is used.
67        cflags.append('-Werror=unguarded-availability')
68        # We're building with a macOS 11+ SDK, so this should be set, but
69        # configure doesn't find it because of the unguarded-availability error
70        # combined with and older -mmacosx-version-min
71        cflags.append('-DHAVE_DYLD_SHARED_CACHE_CONTAINS_PATH=1')
72    elif host == Host.Linux:
73        # Quoting for -Wl,-rpath,$ORIGIN:
74        #  - To link some binaries, make passes -Wl,-rpath,\$ORIGIN to shell.
75        #  - To build stdlib extension modules, make invokes:
76        #        setup.py LDSHARED='... -Wl,-rpath,\$ORIGIN ...'
77        #  - distutils.util.split_quoted then splits LDSHARED into
78        #    [... "-Wl,-rpath,$ORIGIN", ...].
79        ldflags.append("-Wl,-rpath,\\$$ORIGIN/../lib")
80
81        # Omit DT_NEEDED entries for unused dynamic libraries. This is implicit
82        # with Debian's gcc driver but not with CentOS's gcc driver.
83        ldflags.append('-Wl,--as-needed')
84
85    config_cmd.append('CFLAGS={}'.format(' '.join(cflags)))
86    config_cmd.append('LDFLAGS={}'.format(' '.join(cflags + ldflags + [extra_ldflags])))
87
88    subprocess.check_call(config_cmd, cwd=build_dir, env=env)
89
90    if host == Host.Darwin:
91        # By default, LC_ID_DYLIB for libpython will be set to an absolute path.
92        # Linker will embed this path to all binaries linking this library.
93        # Since configure does not give us a chance to set -install_name, we have
94        # to edit the library afterwards.
95        libpython = 'libpython3.11.dylib'
96        subprocess.check_call(['make',
97                               '-j{}'.format(multiprocessing.cpu_count()),
98                               libpython],
99                              cwd=build_dir)
100        subprocess.check_call(['install_name_tool', '-id', '@rpath/' + libpython,
101                               libpython], cwd=build_dir)
102
103    subprocess.check_call(['make',
104                           '-j{}'.format(multiprocessing.cpu_count()),
105                           'install'],
106                          cwd=build_dir)
107    return (build_dir, install_dir)
108
109
110def install_licenses(host, install_dir, extra_notices):
111    (license_path,) = glob.glob(f'{install_dir}/lib/python*/LICENSE.txt')
112    with open(license_path, 'a') as out:
113        for notice in extra_notices:
114            out.write('\n-------------------------------------------------------------------\n\n')
115            with open(notice) as inp:
116                out.write(inp.read())
117
118
119def package_target(host, install_dir, dest_dir, build_id):
120    package_name = 'python3-{}-{}.tar.bz2'.format(host.value, build_id)
121    package_path = os.path.join(dest_dir, package_name)
122
123    os.makedirs(dest_dir, exist_ok=True)
124    print('## Packaging Python ##')
125    print('## Package     : {}'.format(package_path))
126    print('## Install Dir : {}'.format(install_dir))
127    sys.stdout.flush()
128
129    # Libs to exclude, from PC/layout/main.py, get_lib_layout().
130    EXCLUDES = [
131      "lib/python*/config-*",
132      # EXCLUDE_FROM_LIB
133      "*.pyc", "__pycache__", "*.pickle",
134      # TEST_DIRS_ONLY
135      "test", "tests",
136      # TCLTK_DIRS_ONLY
137      "tkinter", "turtledemo",
138      # IDLE_DIRS_ONLY
139      "idlelib",
140      # TCLTK_FILES_ONLY
141      "turtle.py",
142      # BDIST_WININST_FILES_ONLY
143      "wininst-*", "bdist_wininst.py",
144    ]
145    tar_cmd = ['tar']
146    for pattern in EXCLUDES:
147      tar_cmd.append('--exclude')
148      tar_cmd.append(pattern)
149    tar_cmd.extend(['-cjf', package_path, '.'])
150    print(subprocess.list2cmdline(tar_cmd))
151    subprocess.check_call(tar_cmd, cwd=install_dir)
152
153
154def package_logs(out_dir, dest_dir):
155    os.makedirs(dest_dir, exist_ok=True)
156    print('## Packaging Logs ##')
157    sys.stdout.flush()
158    with tarfile.open(os.path.join(dest_dir, "logs.tar.bz2"), "w:bz2") as tar:
159        tar.add(os.path.join(out_dir, 'config.log'), arcname='config.log')
160
161
162def main(argv):
163    python_src = argv[1]
164    out_dir = argv[2]
165    dest_dir = argv[3]
166    build_id = argv[4]
167    extra_ldflags = argv[5]
168    extra_notices = argv[6].split()
169    host = get_default_host()
170
171    build_dir = os.path.join(out_dir, 'build')
172    install_dir = os.path.join(out_dir, 'install')
173
174    try:
175        build_autoconf_target(host, python_src, build_dir, install_dir,
176                              extra_ldflags)
177        install_licenses(host, install_dir, extra_notices)
178        package_target(host, install_dir, dest_dir, build_id)
179    except:
180        # Keep logs before exit.
181        package_logs(build_dir, dest_dir)
182        raise
183
184
185if __name__ == '__main__':
186    main(sys.argv)
187