#!/usr/bin/env python3 # PyMultiC - Python Multiple Compiler import os import sys import subprocess import shutil import re PYVERS = { '1.0': '1.0.1', '1.1': '1.1', '1.2': '1.2', '1.3': '1.3', '1.4': '1.4', '1.5': '1.5.2', '1.6': '1.6.1', '2.0': '2.0.1', '2.1': '2.1.3', '2.2': '2.2.3', '2.3': '2.3.7', '2.4': '2.4.6', '2.5': '2.5.6', '2.6': '2.6.9', '2.7': '2.7.18', '3.0': '3.0.1', '3.1': '3.1.5', '3.2': '3.2.6', '3.3': '3.3.7', '3.4': '3.4.10', '3.5': '3.5.10', '3.6': '3.6.13', '3.7': '3.7.10', '3.8': '3.8.9', '3.9': '3.9.4', '3.10': '3.10.0', } OLD_PYTHONS = ('1.0', '1.1', '1.2', '1.3', '1.4', '1.5') OLD_PYURL = 'https://legacy.python.org/download/releases/src' PYURL = 'https://www.python.org/ftp/python' # Not all versions of Python have an official container. PYVER_CONTAINERS = { '2.7', '3.2', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', } USE_CONTAINERS = False CONTAINER_EXES = ['docker', 'podman'] def fetch_python(snekdir, version): realver = PYVERS[version] if version in ('1.0', '1.1'): tarball = 'python{}.tar.gz'.format(realver) url = '{}/{}'.format(OLD_PYURL, tarball) elif version in ('1.2', '1.3', '1.4', '1.5'): tarball = 'python-{}.tar.gz'.format(realver) url = '{}/{}'.format(OLD_PYURL, tarball) elif version == '1.6': tarball = 'Python-{}.tar.gz'.format(realver) url = None else: tarball = 'Python-{}.tgz'.format(realver) url = '{}/{}/{}'.format(PYURL, realver, tarball) pyver_dir = os.path.join(snekdir, 'Python-{}'.format(realver)) if os.path.exists(pyver_dir): return tb_dir = os.path.join(snekdir, 'tarballs') if not os.path.exists(tb_dir): os.makedirs(tb_dir) tarfile = os.path.join(tb_dir, tarball) if not os.path.exists(tarfile): if version == '1.6': print('Python 1.6.1 cannot be downloaded automatically due to a license agreement') print('which must be manually accepted.') print('Please download it from https://www.python.org/download/releases/1.6.1/download/') print('and place the tarball in {}'.format(tb_dir)) sys.exit(1) print('Downloading Python {}...'.format(realver)) if subprocess.call(['curl', '-LfO#', url], cwd=tb_dir) != 0: sys.exit(1) print('Extracting Python {}...'.format(realver)) if subprocess.call(['tar', 'xaf', tarfile], cwd=snekdir) != 0: sys.exit(1) if os.path.exists(os.path.join(snekdir, 'python-{}'.format(realver))) \ and not os.path.exists(pyver_dir): # The dual check prevents useless renames on case-insensitive # file systems os.rename(os.path.join(snekdir, 'python-{}'.format(realver)), pyver_dir) patch_file = os.path.join(snekdir, 'python-builds', 'Python-{}.patch'.format(realver)) if os.path.exists(patch_file): if subprocess.call(['patch', '-p1', '-i', patch_file], cwd=pyver_dir) != 0: sys.exit(1) def build_python(snekdir, version): realver = PYVERS[version] snek = 'Python-{}'.format(realver) builddir = os.path.join(snekdir, snek) # NOTE: This has only been tested on a Debian 10 x86_64 system -- it is # probably not as robust as it should be... print('Configuring Python {}...'.format(realver)) logfile = os.path.join(snekdir, '{}.conf.log'.format(snek)) with open(logfile, 'wb') as log: if subprocess.call(['./configure'], stdout=log, stderr=log, cwd=builddir) != 0: print('... Configuration failed. See {} for details'.format(logfile)) sys.exit(1) print('Building Python {}...'.format(realver)) logfile = os.path.join(snekdir, '{}.build.log'.format(snek)) with open(logfile, 'wb') as log: if subprocess.call(['make'], stdout=log, stderr=log, cwd=builddir) != 0: print('... Build failed. See {} for details'.format(logfile)) sys.exit(1) def acquire_python(snekdir, version): snek = 'Python-{}'.format(PYVERS[version]) pyexe = os.path.join(snekdir, snek, 'python') if not os.path.exists(pyexe): fetch_python(snekdir, version) build_python(snekdir, version) return pyexe def local_compile(snekdir, ver, infile): pyexe = acquire_python(snekdir, ver) proc = subprocess.Popen([pyexe, '-c', 'import sys; print(sys.version)'], stdout=subprocess.PIPE) out, _ = proc.communicate() if proc.returncode != 0: print('Could not determine Python version for {}'.format(ver)) return None bcver = str(out, 'iso-8859-1').split(' ', 1)[0] if not bcver.startswith(ver): print('Python {} reported itself as version {}!'.format(ver, bcver)) return None if infile.endswith('.py'): outfile = os.path.basename(infile)[:-3] else: outfile = os.path.basename(infile) outfile += '.{}.pyc'.format(ver) if os.path.exists(outfile): os.unlink(outfile) print('*** Compiling for Python {}'.format(bcver)) if ver in {'1.0', '1.1', '1.2', '1.3', '1.4'}: # The hard way -- hope your code is safe... srcdir = os.path.dirname(os.path.realpath(infile)) comptmp = os.path.join(srcdir, 'pymc_temp.py') if os.path.exists(comptmp): os.unlink(comptmp) shutil.copyfile(infile, comptmp) cwdsave = os.getcwd() os.chdir(srcdir) proc = subprocess.Popen([pyexe, '-c', 'import pymc_temp']) proc.communicate() os.chdir(cwdsave) if os.path.exists(comptmp + 'o'): shutil.copyfile(comptmp + 'o', outfile) os.unlink(comptmp + 'o') elif os.path.exists(comptmp + 'c'): shutil.copyfile(comptmp + 'c', outfile) os.unlink(comptmp + 'c') os.unlink(comptmp) else: # The easy way proc = subprocess.Popen([pyexe, '-c', "import py_compile; py_compile.compile('{}', '{}')" \ .format(infile, outfile)]) proc.communicate() return outfile def container_compile(snekdir, ver, infile): if ver not in PYVER_CONTAINERS: print('Container compilation not supported for version {}'.format(ver)) return None container_exe = None for ce in CONTAINER_EXES: if shutil.which(ce) is not None: container_exe = ce break if container_exe is None: print('Cannot find {} in $PATH'.format(' or '.join(CONTAINER_EXES))) return None fullver = PYVERS[ver] if infile.endswith('.py'): outfile = os.path.basename(infile)[:-3] else: outfile = os.path.basename(infile) outfile += '.{}.pyc'.format(ver) if os.path.exists(outfile): os.unlink(outfile) print('*** Compiling for Python {}'.format(fullver)) # The easy way proc = subprocess.Popen([container_exe, 'run', '--privileged', '--rm', '--name', '{}-{}'.format(infile, ver), '-v', '{}:/src'.format(snekdir), '-w', '/src', 'python:{}'.format(fullver), 'python', '-c', "import py_compile; py_compile.compile('{}', '{}')".format(infile, outfile)]) proc.communicate() return outfile if len(sys.argv) < 2: print('Usage: {} [-c] [versions] input.py'.format(sys.argv[0])) print('Compile input.py for one or more python versions') print() print('-c\tuse prebuilt containers for running different versions of Python (only available for versions >= {})' .format(PYVER_CONTAINER_MIN)) print() print('Output is written to input..pyc for each version successfully compiled') print() print('Version is X.Y (e.g. 3.4), not including the patch version') sys.exit(1) RE_PYVER = re.compile(r'\d\.\d') pythons = [] infile = None for arg in sys.argv[1:]: if RE_PYVER.match(arg): if arg in PYVERS.keys(): pythons.append(arg) else: print('Unknown Python version: {}'.format(arg)) sys.exit(1) elif arg == '-c': USE_CONTAINERS = True elif arg.startswith('-'): print("WARNING: Unrecognized argument '{}'".format(arg)) else: infile = arg if infile is None: print('No input file specified') sys.exit(1) elif not os.path.exists(infile): print('Error: Input file {} does not exist'.format(infile)) sys.exit(1) if len(pythons) == 0: print('At least one Python version is required') sys.exit(1) snekdir = os.path.dirname(os.path.realpath(__file__)) result = 0 for ver in pythons: compile_with_container = USE_CONTAINERS if USE_CONTAINERS and ver not in PYVER_CONTAINERS: print('Warning: No supported container for {} - using local build'.format(ver)) compile_with_container = False outfile = None if compile_with_container: outfile = container_compile(snekdir, ver, infile) else: outfile = local_compile(snekdir, ver, infile) if outfile is None or not os.path.exists(outfile): result = 1 sys.exit(result)