blob: c23b788d1c9b7dcc5fb3686a33183e24c8c963f6 [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):
Serhiy Storchaka4aec9a82017-03-25 13:05:23 +020039 if isinstance(archive, (str, os.PathLike)):
Brett Cannoncc4dfc12015-03-13 10:40:49 -040040 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:
Paul Moorea4d4dd32015-03-22 15:32:36 +000049 shebang = b'#!' + interpreter.encode(shebang_encoding) + b'\n'
Brett Cannoncc4dfc12015-03-13 10:40:49 -040050 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
Serhiy Storchaka6a7b3a72016-04-17 08:32:47 +030090 not specified, an existing __main__.py will be used). It is an error
91 to specify MAIN for anything other than a directory source with no
92 __main__.py, and it is an error to omit MAIN if the directory has no
93 __main__.py.
Brett Cannoncc4dfc12015-03-13 10:40:49 -040094 """
95 # Are we copying an existing archive?
Paul Moorea4d4dd32015-03-22 15:32:36 +000096 source_is_file = False
97 if hasattr(source, 'read') and hasattr(source, 'readline'):
98 source_is_file = True
99 else:
100 source = pathlib.Path(source)
101 if source.is_file():
102 source_is_file = True
103
104 if source_is_file:
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400105 _copy_archive(source, target, interpreter)
106 return
107
Brett Cannon469d0fb2015-03-13 11:13:20 -0400108 # We are creating a new archive from a directory.
Paul Moorea4d4dd32015-03-22 15:32:36 +0000109 if not source.exists():
110 raise ZipAppError("Source does not exist")
111 has_main = (source / '__main__.py').is_file()
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400112 if main and has_main:
113 raise ZipAppError(
114 "Cannot specify entry point if the source has __main__.py")
115 if not (main or has_main):
116 raise ZipAppError("Archive has no entry point")
117
118 main_py = None
119 if main:
Brett Cannon469d0fb2015-03-13 11:13:20 -0400120 # Check that main has the right format.
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400121 mod, sep, fn = main.partition(':')
122 mod_ok = all(part.isidentifier() for part in mod.split('.'))
123 fn_ok = all(part.isidentifier() for part in fn.split('.'))
124 if not (sep == ':' and mod_ok and fn_ok):
125 raise ZipAppError("Invalid entry point: " + main)
126 main_py = MAIN_TEMPLATE.format(module=mod, fn=fn)
127
128 if target is None:
Paul Moorea4d4dd32015-03-22 15:32:36 +0000129 target = source.with_suffix('.pyz')
130 elif not hasattr(target, 'write'):
131 target = pathlib.Path(target)
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400132
133 with _maybe_open(target, 'wb') as fd:
134 _write_file_prefix(fd, interpreter)
135 with zipfile.ZipFile(fd, 'w') as z:
Serhiy Storchaka4aec9a82017-03-25 13:05:23 +0200136 for child in source.rglob('*'):
137 arcname = child.relative_to(source).as_posix()
138 z.write(child, arcname)
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400139 if main_py:
140 z.writestr('__main__.py', main_py.encode('utf-8'))
141
Paul Moorea4d4dd32015-03-22 15:32:36 +0000142 if interpreter and not hasattr(target, 'write'):
143 target.chmod(target.stat().st_mode | stat.S_IEXEC)
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400144
145
146def get_interpreter(archive):
147 with _maybe_open(archive, 'rb') as f:
148 if f.read(2) == b'#!':
149 return f.readline().strip().decode(shebang_encoding)
150
151
Paul Moorea4d4dd32015-03-22 15:32:36 +0000152def main(args=None):
153 """Run the zipapp command line interface.
154
155 The ARGS parameter lets you specify the argument list directly.
156 Omitting ARGS (or setting it to None) works as for argparse, using
157 sys.argv[1:] as the argument list.
158 """
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400159 import argparse
160
161 parser = argparse.ArgumentParser()
162 parser.add_argument('--output', '-o', default=None,
163 help="The name of the output archive. "
164 "Required if SOURCE is an archive.")
165 parser.add_argument('--python', '-p', default=None,
166 help="The name of the Python interpreter to use "
167 "(default: no shebang line).")
168 parser.add_argument('--main', '-m', default=None,
169 help="The main function of the application "
170 "(default: use an existing __main__.py).")
171 parser.add_argument('--info', default=False, action='store_true',
172 help="Display the interpreter from the archive.")
173 parser.add_argument('source',
174 help="Source directory (or existing archive).")
175
Paul Moorea4d4dd32015-03-22 15:32:36 +0000176 args = parser.parse_args(args)
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400177
178 # Handle `python -m zipapp archive.pyz --info`.
179 if args.info:
180 if not os.path.isfile(args.source):
181 raise SystemExit("Can only get info for an archive file")
182 interpreter = get_interpreter(args.source)
183 print("Interpreter: {}".format(interpreter or "<none>"))
184 sys.exit(0)
185
186 if os.path.isfile(args.source):
Paul Moorea4d4dd32015-03-22 15:32:36 +0000187 if args.output is None or (os.path.exists(args.output) and
188 os.path.samefile(args.source, args.output)):
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400189 raise SystemExit("In-place editing of archives is not supported")
190 if args.main:
191 raise SystemExit("Cannot change the main function when copying")
192
193 create_archive(args.source, args.output,
194 interpreter=args.python, main=args.main)
195
196
197if __name__ == '__main__':
198 main()