blob: 3b8f9bfa35c139a0458471f31157a2f439b052b4 [file] [log] [blame]
Brett Cannoncc4dfc12015-03-13 10:40:49 -04001import contextlib
2import os
3import pathlib
4import shutil
5import stat
6import sys
7import 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.
17MAIN_TEMPLATE = """\
18# -*- coding: utf-8 -*-
19import {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.
27if sys.platform.startswith('win'):
28 shebang_encoding = 'utf-8'
29else:
30 shebang_encoding = sys.getfilesystemencoding()
31
32
33class ZipAppError(ValueError):
34 pass
35
36
37@contextlib.contextmanager
38def _maybe_open(archive, mode):
39 if isinstance(archive, str):
40 with open(archive, mode) as f:
41 yield f
42 else:
43 yield archive
44
45
46def _write_file_prefix(f, interpreter):
47 """Write a shebang line."""
48 if interpreter:
49 shebang = b'#!%b\n' % (interpreter.encode(shebang_encoding),)
50 f.write(shebang)
51
52
53def _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
76def create_archive(source, target=None, interpreter=None, main=None):
77 """Create an application archive from SOURCE.
78
79 The SOURCE can be the name of a directory, or a filename or a file-like
80 object referring to an existing archive.
81
82 The content of SOURCE is packed into an application archive in TARGET,
83 which can be a filename or a file-like object. If SOURCE is a directory,
84 TARGET can be omitted and will default to the name of SOURCE with .pyz
85 appended.
86
87 The created application archive will have a shebang line specifying
88 that it should run with INTERPRETER (there will be no shebang line if
89 INTERPRETER is None), and a __main__.py which runs MAIN (if MAIN is
90 not specified, an existing __main__.py will be used). It is an to specify
91 MAIN for anything other than a directory source with no __main__.py, and it
92 is an error to omit MAIN if the directory has no __main__.py.
93 """
94 # Are we copying an existing archive?
95 if not (isinstance(source, str) and os.path.isdir(source)):
96 _copy_archive(source, target, interpreter)
97 return
98
99 # We are creating a new archive from a directory
100 has_main = os.path.exists(os.path.join(source, '__main__.py'))
101 if main and has_main:
102 raise ZipAppError(
103 "Cannot specify entry point if the source has __main__.py")
104 if not (main or has_main):
105 raise ZipAppError("Archive has no entry point")
106
107 main_py = None
108 if main:
109 # Check that main has the right format
110 mod, sep, fn = main.partition(':')
111 mod_ok = all(part.isidentifier() for part in mod.split('.'))
112 fn_ok = all(part.isidentifier() for part in fn.split('.'))
113 if not (sep == ':' and mod_ok and fn_ok):
114 raise ZipAppError("Invalid entry point: " + main)
115 main_py = MAIN_TEMPLATE.format(module=mod, fn=fn)
116
117 if target is None:
118 target = source + '.pyz'
119
120 with _maybe_open(target, 'wb') as fd:
121 _write_file_prefix(fd, interpreter)
122 with zipfile.ZipFile(fd, 'w') as z:
123 root = pathlib.Path(source)
124 for child in root.rglob('*'):
125 arcname = str(child.relative_to(root))
126 z.write(str(child), arcname)
127 if main_py:
128 z.writestr('__main__.py', main_py.encode('utf-8'))
129
130 if interpreter and isinstance(target, str):
131 os.chmod(target, os.stat(target).st_mode | stat.S_IEXEC)
132
133
134def get_interpreter(archive):
135 with _maybe_open(archive, 'rb') as f:
136 if f.read(2) == b'#!':
137 return f.readline().strip().decode(shebang_encoding)
138
139
140def main():
141 import argparse
142
143 parser = argparse.ArgumentParser()
144 parser.add_argument('--output', '-o', default=None,
145 help="The name of the output archive. "
146 "Required if SOURCE is an archive.")
147 parser.add_argument('--python', '-p', default=None,
148 help="The name of the Python interpreter to use "
149 "(default: no shebang line).")
150 parser.add_argument('--main', '-m', default=None,
151 help="The main function of the application "
152 "(default: use an existing __main__.py).")
153 parser.add_argument('--info', default=False, action='store_true',
154 help="Display the interpreter from the archive.")
155 parser.add_argument('source',
156 help="Source directory (or existing archive).")
157
158 args = parser.parse_args()
159
160 # Handle `python -m zipapp archive.pyz --info`.
161 if args.info:
162 if not os.path.isfile(args.source):
163 raise SystemExit("Can only get info for an archive file")
164 interpreter = get_interpreter(args.source)
165 print("Interpreter: {}".format(interpreter or "<none>"))
166 sys.exit(0)
167
168 if os.path.isfile(args.source):
169 if args.output is None or os.path.samefile(args.source, args.output):
170 raise SystemExit("In-place editing of archives is not supported")
171 if args.main:
172 raise SystemExit("Cannot change the main function when copying")
173
174 create_archive(args.source, args.output,
175 interpreter=args.python, main=args.main)
176
177
178if __name__ == '__main__':
179 main()