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