1*cda5da8dSAndroid Build Coastguard Workerimport contextlib 2*cda5da8dSAndroid Build Coastguard Workerimport os 3*cda5da8dSAndroid Build Coastguard Workerimport pathlib 4*cda5da8dSAndroid Build Coastguard Workerimport shutil 5*cda5da8dSAndroid Build Coastguard Workerimport stat 6*cda5da8dSAndroid Build Coastguard Workerimport sys 7*cda5da8dSAndroid Build Coastguard Workerimport zipfile 8*cda5da8dSAndroid Build Coastguard Worker 9*cda5da8dSAndroid Build Coastguard Worker__all__ = ['ZipAppError', 'create_archive', 'get_interpreter'] 10*cda5da8dSAndroid Build Coastguard Worker 11*cda5da8dSAndroid Build Coastguard Worker 12*cda5da8dSAndroid Build Coastguard Worker# The __main__.py used if the users specifies "-m module:fn". 13*cda5da8dSAndroid Build Coastguard Worker# Note that this will always be written as UTF-8 (module and 14*cda5da8dSAndroid Build Coastguard Worker# function names can be non-ASCII in Python 3). 15*cda5da8dSAndroid Build Coastguard Worker# We add a coding cookie even though UTF-8 is the default in Python 3 16*cda5da8dSAndroid Build Coastguard Worker# because the resulting archive may be intended to be run under Python 2. 17*cda5da8dSAndroid Build Coastguard WorkerMAIN_TEMPLATE = """\ 18*cda5da8dSAndroid Build Coastguard Worker# -*- coding: utf-8 -*- 19*cda5da8dSAndroid Build Coastguard Workerimport {module} 20*cda5da8dSAndroid Build Coastguard Worker{module}.{fn}() 21*cda5da8dSAndroid Build Coastguard Worker""" 22*cda5da8dSAndroid Build Coastguard Worker 23*cda5da8dSAndroid Build Coastguard Worker 24*cda5da8dSAndroid Build Coastguard Worker# The Windows launcher defaults to UTF-8 when parsing shebang lines if the 25*cda5da8dSAndroid Build Coastguard Worker# file has no BOM. So use UTF-8 on Windows. 26*cda5da8dSAndroid Build Coastguard Worker# On Unix, use the filesystem encoding. 27*cda5da8dSAndroid Build Coastguard Workerif sys.platform.startswith('win'): 28*cda5da8dSAndroid Build Coastguard Worker shebang_encoding = 'utf-8' 29*cda5da8dSAndroid Build Coastguard Workerelse: 30*cda5da8dSAndroid Build Coastguard Worker shebang_encoding = sys.getfilesystemencoding() 31*cda5da8dSAndroid Build Coastguard Worker 32*cda5da8dSAndroid Build Coastguard Worker 33*cda5da8dSAndroid Build Coastguard Workerclass ZipAppError(ValueError): 34*cda5da8dSAndroid Build Coastguard Worker pass 35*cda5da8dSAndroid Build Coastguard Worker 36*cda5da8dSAndroid Build Coastguard Worker 37*cda5da8dSAndroid Build Coastguard Worker@contextlib.contextmanager 38*cda5da8dSAndroid Build Coastguard Workerdef _maybe_open(archive, mode): 39*cda5da8dSAndroid Build Coastguard Worker if isinstance(archive, (str, os.PathLike)): 40*cda5da8dSAndroid Build Coastguard Worker with open(archive, mode) as f: 41*cda5da8dSAndroid Build Coastguard Worker yield f 42*cda5da8dSAndroid Build Coastguard Worker else: 43*cda5da8dSAndroid Build Coastguard Worker yield archive 44*cda5da8dSAndroid Build Coastguard Worker 45*cda5da8dSAndroid Build Coastguard Worker 46*cda5da8dSAndroid Build Coastguard Workerdef _write_file_prefix(f, interpreter): 47*cda5da8dSAndroid Build Coastguard Worker """Write a shebang line.""" 48*cda5da8dSAndroid Build Coastguard Worker if interpreter: 49*cda5da8dSAndroid Build Coastguard Worker shebang = b'#!' + interpreter.encode(shebang_encoding) + b'\n' 50*cda5da8dSAndroid Build Coastguard Worker f.write(shebang) 51*cda5da8dSAndroid Build Coastguard Worker 52*cda5da8dSAndroid Build Coastguard Worker 53*cda5da8dSAndroid Build Coastguard Workerdef _copy_archive(archive, new_archive, interpreter=None): 54*cda5da8dSAndroid Build Coastguard Worker """Copy an application archive, modifying the shebang line.""" 55*cda5da8dSAndroid Build Coastguard Worker with _maybe_open(archive, 'rb') as src: 56*cda5da8dSAndroid Build Coastguard Worker # Skip the shebang line from the source. 57*cda5da8dSAndroid Build Coastguard Worker # Read 2 bytes of the source and check if they are #!. 58*cda5da8dSAndroid Build Coastguard Worker first_2 = src.read(2) 59*cda5da8dSAndroid Build Coastguard Worker if first_2 == b'#!': 60*cda5da8dSAndroid Build Coastguard Worker # Discard the initial 2 bytes and the rest of the shebang line. 61*cda5da8dSAndroid Build Coastguard Worker first_2 = b'' 62*cda5da8dSAndroid Build Coastguard Worker src.readline() 63*cda5da8dSAndroid Build Coastguard Worker 64*cda5da8dSAndroid Build Coastguard Worker with _maybe_open(new_archive, 'wb') as dst: 65*cda5da8dSAndroid Build Coastguard Worker _write_file_prefix(dst, interpreter) 66*cda5da8dSAndroid Build Coastguard Worker # If there was no shebang, "first_2" contains the first 2 bytes 67*cda5da8dSAndroid Build Coastguard Worker # of the source file, so write them before copying the rest 68*cda5da8dSAndroid Build Coastguard Worker # of the file. 69*cda5da8dSAndroid Build Coastguard Worker dst.write(first_2) 70*cda5da8dSAndroid Build Coastguard Worker shutil.copyfileobj(src, dst) 71*cda5da8dSAndroid Build Coastguard Worker 72*cda5da8dSAndroid Build Coastguard Worker if interpreter and isinstance(new_archive, str): 73*cda5da8dSAndroid Build Coastguard Worker os.chmod(new_archive, os.stat(new_archive).st_mode | stat.S_IEXEC) 74*cda5da8dSAndroid Build Coastguard Worker 75*cda5da8dSAndroid Build Coastguard Worker 76*cda5da8dSAndroid Build Coastguard Workerdef create_archive(source, target=None, interpreter=None, main=None, 77*cda5da8dSAndroid Build Coastguard Worker filter=None, compressed=False): 78*cda5da8dSAndroid Build Coastguard Worker """Create an application archive from SOURCE. 79*cda5da8dSAndroid Build Coastguard Worker 80*cda5da8dSAndroid Build Coastguard Worker The SOURCE can be the name of a directory, or a filename or a file-like 81*cda5da8dSAndroid Build Coastguard Worker object referring to an existing archive. 82*cda5da8dSAndroid Build Coastguard Worker 83*cda5da8dSAndroid Build Coastguard Worker The content of SOURCE is packed into an application archive in TARGET, 84*cda5da8dSAndroid Build Coastguard Worker which can be a filename or a file-like object. If SOURCE is a directory, 85*cda5da8dSAndroid Build Coastguard Worker TARGET can be omitted and will default to the name of SOURCE with .pyz 86*cda5da8dSAndroid Build Coastguard Worker appended. 87*cda5da8dSAndroid Build Coastguard Worker 88*cda5da8dSAndroid Build Coastguard Worker The created application archive will have a shebang line specifying 89*cda5da8dSAndroid Build Coastguard Worker that it should run with INTERPRETER (there will be no shebang line if 90*cda5da8dSAndroid Build Coastguard Worker INTERPRETER is None), and a __main__.py which runs MAIN (if MAIN is 91*cda5da8dSAndroid Build Coastguard Worker not specified, an existing __main__.py will be used). It is an error 92*cda5da8dSAndroid Build Coastguard Worker to specify MAIN for anything other than a directory source with no 93*cda5da8dSAndroid Build Coastguard Worker __main__.py, and it is an error to omit MAIN if the directory has no 94*cda5da8dSAndroid Build Coastguard Worker __main__.py. 95*cda5da8dSAndroid Build Coastguard Worker """ 96*cda5da8dSAndroid Build Coastguard Worker # Are we copying an existing archive? 97*cda5da8dSAndroid Build Coastguard Worker source_is_file = False 98*cda5da8dSAndroid Build Coastguard Worker if hasattr(source, 'read') and hasattr(source, 'readline'): 99*cda5da8dSAndroid Build Coastguard Worker source_is_file = True 100*cda5da8dSAndroid Build Coastguard Worker else: 101*cda5da8dSAndroid Build Coastguard Worker source = pathlib.Path(source) 102*cda5da8dSAndroid Build Coastguard Worker if source.is_file(): 103*cda5da8dSAndroid Build Coastguard Worker source_is_file = True 104*cda5da8dSAndroid Build Coastguard Worker 105*cda5da8dSAndroid Build Coastguard Worker if source_is_file: 106*cda5da8dSAndroid Build Coastguard Worker _copy_archive(source, target, interpreter) 107*cda5da8dSAndroid Build Coastguard Worker return 108*cda5da8dSAndroid Build Coastguard Worker 109*cda5da8dSAndroid Build Coastguard Worker # We are creating a new archive from a directory. 110*cda5da8dSAndroid Build Coastguard Worker if not source.exists(): 111*cda5da8dSAndroid Build Coastguard Worker raise ZipAppError("Source does not exist") 112*cda5da8dSAndroid Build Coastguard Worker has_main = (source / '__main__.py').is_file() 113*cda5da8dSAndroid Build Coastguard Worker if main and has_main: 114*cda5da8dSAndroid Build Coastguard Worker raise ZipAppError( 115*cda5da8dSAndroid Build Coastguard Worker "Cannot specify entry point if the source has __main__.py") 116*cda5da8dSAndroid Build Coastguard Worker if not (main or has_main): 117*cda5da8dSAndroid Build Coastguard Worker raise ZipAppError("Archive has no entry point") 118*cda5da8dSAndroid Build Coastguard Worker 119*cda5da8dSAndroid Build Coastguard Worker main_py = None 120*cda5da8dSAndroid Build Coastguard Worker if main: 121*cda5da8dSAndroid Build Coastguard Worker # Check that main has the right format. 122*cda5da8dSAndroid Build Coastguard Worker mod, sep, fn = main.partition(':') 123*cda5da8dSAndroid Build Coastguard Worker mod_ok = all(part.isidentifier() for part in mod.split('.')) 124*cda5da8dSAndroid Build Coastguard Worker fn_ok = all(part.isidentifier() for part in fn.split('.')) 125*cda5da8dSAndroid Build Coastguard Worker if not (sep == ':' and mod_ok and fn_ok): 126*cda5da8dSAndroid Build Coastguard Worker raise ZipAppError("Invalid entry point: " + main) 127*cda5da8dSAndroid Build Coastguard Worker main_py = MAIN_TEMPLATE.format(module=mod, fn=fn) 128*cda5da8dSAndroid Build Coastguard Worker 129*cda5da8dSAndroid Build Coastguard Worker if target is None: 130*cda5da8dSAndroid Build Coastguard Worker target = source.with_suffix('.pyz') 131*cda5da8dSAndroid Build Coastguard Worker elif not hasattr(target, 'write'): 132*cda5da8dSAndroid Build Coastguard Worker target = pathlib.Path(target) 133*cda5da8dSAndroid Build Coastguard Worker 134*cda5da8dSAndroid Build Coastguard Worker with _maybe_open(target, 'wb') as fd: 135*cda5da8dSAndroid Build Coastguard Worker _write_file_prefix(fd, interpreter) 136*cda5da8dSAndroid Build Coastguard Worker compression = (zipfile.ZIP_DEFLATED if compressed else 137*cda5da8dSAndroid Build Coastguard Worker zipfile.ZIP_STORED) 138*cda5da8dSAndroid Build Coastguard Worker with zipfile.ZipFile(fd, 'w', compression=compression) as z: 139*cda5da8dSAndroid Build Coastguard Worker for child in source.rglob('*'): 140*cda5da8dSAndroid Build Coastguard Worker arcname = child.relative_to(source) 141*cda5da8dSAndroid Build Coastguard Worker if filter is None or filter(arcname): 142*cda5da8dSAndroid Build Coastguard Worker z.write(child, arcname.as_posix()) 143*cda5da8dSAndroid Build Coastguard Worker if main_py: 144*cda5da8dSAndroid Build Coastguard Worker z.writestr('__main__.py', main_py.encode('utf-8')) 145*cda5da8dSAndroid Build Coastguard Worker 146*cda5da8dSAndroid Build Coastguard Worker if interpreter and not hasattr(target, 'write'): 147*cda5da8dSAndroid Build Coastguard Worker target.chmod(target.stat().st_mode | stat.S_IEXEC) 148*cda5da8dSAndroid Build Coastguard Worker 149*cda5da8dSAndroid Build Coastguard Worker 150*cda5da8dSAndroid Build Coastguard Workerdef get_interpreter(archive): 151*cda5da8dSAndroid Build Coastguard Worker with _maybe_open(archive, 'rb') as f: 152*cda5da8dSAndroid Build Coastguard Worker if f.read(2) == b'#!': 153*cda5da8dSAndroid Build Coastguard Worker return f.readline().strip().decode(shebang_encoding) 154*cda5da8dSAndroid Build Coastguard Worker 155*cda5da8dSAndroid Build Coastguard Worker 156*cda5da8dSAndroid Build Coastguard Workerdef main(args=None): 157*cda5da8dSAndroid Build Coastguard Worker """Run the zipapp command line interface. 158*cda5da8dSAndroid Build Coastguard Worker 159*cda5da8dSAndroid Build Coastguard Worker The ARGS parameter lets you specify the argument list directly. 160*cda5da8dSAndroid Build Coastguard Worker Omitting ARGS (or setting it to None) works as for argparse, using 161*cda5da8dSAndroid Build Coastguard Worker sys.argv[1:] as the argument list. 162*cda5da8dSAndroid Build Coastguard Worker """ 163*cda5da8dSAndroid Build Coastguard Worker import argparse 164*cda5da8dSAndroid Build Coastguard Worker 165*cda5da8dSAndroid Build Coastguard Worker parser = argparse.ArgumentParser() 166*cda5da8dSAndroid Build Coastguard Worker parser.add_argument('--output', '-o', default=None, 167*cda5da8dSAndroid Build Coastguard Worker help="The name of the output archive. " 168*cda5da8dSAndroid Build Coastguard Worker "Required if SOURCE is an archive.") 169*cda5da8dSAndroid Build Coastguard Worker parser.add_argument('--python', '-p', default=None, 170*cda5da8dSAndroid Build Coastguard Worker help="The name of the Python interpreter to use " 171*cda5da8dSAndroid Build Coastguard Worker "(default: no shebang line).") 172*cda5da8dSAndroid Build Coastguard Worker parser.add_argument('--main', '-m', default=None, 173*cda5da8dSAndroid Build Coastguard Worker help="The main function of the application " 174*cda5da8dSAndroid Build Coastguard Worker "(default: use an existing __main__.py).") 175*cda5da8dSAndroid Build Coastguard Worker parser.add_argument('--compress', '-c', action='store_true', 176*cda5da8dSAndroid Build Coastguard Worker help="Compress files with the deflate method. " 177*cda5da8dSAndroid Build Coastguard Worker "Files are stored uncompressed by default.") 178*cda5da8dSAndroid Build Coastguard Worker parser.add_argument('--info', default=False, action='store_true', 179*cda5da8dSAndroid Build Coastguard Worker help="Display the interpreter from the archive.") 180*cda5da8dSAndroid Build Coastguard Worker parser.add_argument('source', 181*cda5da8dSAndroid Build Coastguard Worker help="Source directory (or existing archive).") 182*cda5da8dSAndroid Build Coastguard Worker 183*cda5da8dSAndroid Build Coastguard Worker args = parser.parse_args(args) 184*cda5da8dSAndroid Build Coastguard Worker 185*cda5da8dSAndroid Build Coastguard Worker # Handle `python -m zipapp archive.pyz --info`. 186*cda5da8dSAndroid Build Coastguard Worker if args.info: 187*cda5da8dSAndroid Build Coastguard Worker if not os.path.isfile(args.source): 188*cda5da8dSAndroid Build Coastguard Worker raise SystemExit("Can only get info for an archive file") 189*cda5da8dSAndroid Build Coastguard Worker interpreter = get_interpreter(args.source) 190*cda5da8dSAndroid Build Coastguard Worker print("Interpreter: {}".format(interpreter or "<none>")) 191*cda5da8dSAndroid Build Coastguard Worker sys.exit(0) 192*cda5da8dSAndroid Build Coastguard Worker 193*cda5da8dSAndroid Build Coastguard Worker if os.path.isfile(args.source): 194*cda5da8dSAndroid Build Coastguard Worker if args.output is None or (os.path.exists(args.output) and 195*cda5da8dSAndroid Build Coastguard Worker os.path.samefile(args.source, args.output)): 196*cda5da8dSAndroid Build Coastguard Worker raise SystemExit("In-place editing of archives is not supported") 197*cda5da8dSAndroid Build Coastguard Worker if args.main: 198*cda5da8dSAndroid Build Coastguard Worker raise SystemExit("Cannot change the main function when copying") 199*cda5da8dSAndroid Build Coastguard Worker 200*cda5da8dSAndroid Build Coastguard Worker create_archive(args.source, args.output, 201*cda5da8dSAndroid Build Coastguard Worker interpreter=args.python, main=args.main, 202*cda5da8dSAndroid Build Coastguard Worker compressed=args.compress) 203*cda5da8dSAndroid Build Coastguard Worker 204*cda5da8dSAndroid Build Coastguard Worker 205*cda5da8dSAndroid Build Coastguard Workerif __name__ == '__main__': 206*cda5da8dSAndroid Build Coastguard Worker main() 207