blob: c8380bfeca5e1d4b809a2d1c7a9c19611b483ab6 [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):
Paul Moorea4d4dd32015-03-22 15:32:36 +000039 if isinstance(archive, pathlib.Path):
40 archive = str(archive)
Brett Cannoncc4dfc12015-03-13 10:40:49 -040041 if isinstance(archive, str):
42 with open(archive, mode) as f:
43 yield f
44 else:
45 yield archive
46
47
48def _write_file_prefix(f, interpreter):
49 """Write a shebang line."""
50 if interpreter:
Paul Moorea4d4dd32015-03-22 15:32:36 +000051 shebang = b'#!' + interpreter.encode(shebang_encoding) + b'\n'
Brett Cannoncc4dfc12015-03-13 10:40:49 -040052 f.write(shebang)
53
54
55def _copy_archive(archive, new_archive, interpreter=None):
56 """Copy an application archive, modifying the shebang line."""
57 with _maybe_open(archive, 'rb') as src:
58 # Skip the shebang line from the source.
59 # Read 2 bytes of the source and check if they are #!.
60 first_2 = src.read(2)
61 if first_2 == b'#!':
62 # Discard the initial 2 bytes and the rest of the shebang line.
63 first_2 = b''
64 src.readline()
65
66 with _maybe_open(new_archive, 'wb') as dst:
67 _write_file_prefix(dst, interpreter)
68 # If there was no shebang, "first_2" contains the first 2 bytes
69 # of the source file, so write them before copying the rest
70 # of the file.
71 dst.write(first_2)
72 shutil.copyfileobj(src, dst)
73
74 if interpreter and isinstance(new_archive, str):
75 os.chmod(new_archive, os.stat(new_archive).st_mode | stat.S_IEXEC)
76
77
78def create_archive(source, target=None, interpreter=None, main=None):
79 """Create an application archive from SOURCE.
80
81 The SOURCE can be the name of a directory, or a filename or a file-like
82 object referring to an existing archive.
83
84 The content of SOURCE is packed into an application archive in TARGET,
85 which can be a filename or a file-like object. If SOURCE is a directory,
86 TARGET can be omitted and will default to the name of SOURCE with .pyz
87 appended.
88
89 The created application archive will have a shebang line specifying
90 that it should run with INTERPRETER (there will be no shebang line if
91 INTERPRETER is None), and a __main__.py which runs MAIN (if MAIN is
92 not specified, an existing __main__.py will be used). It is an to specify
93 MAIN for anything other than a directory source with no __main__.py, and it
94 is an error to omit MAIN if the directory has no __main__.py.
95 """
96 # Are we copying an existing archive?
Paul Moorea4d4dd32015-03-22 15:32:36 +000097 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 Cannoncc4dfc12015-03-13 10:40:49 -0400106 _copy_archive(source, target, interpreter)
107 return
108
Brett Cannon469d0fb2015-03-13 11:13:20 -0400109 # We are creating a new archive from a directory.
Paul Moorea4d4dd32015-03-22 15:32:36 +0000110 if not source.exists():
111 raise ZipAppError("Source does not exist")
112 has_main = (source / '__main__.py').is_file()
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400113 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 Cannon469d0fb2015-03-13 11:13:20 -0400121 # Check that main has the right format.
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400122 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 Moorea4d4dd32015-03-22 15:32:36 +0000130 target = source.with_suffix('.pyz')
131 elif not hasattr(target, 'write'):
132 target = pathlib.Path(target)
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400133
134 with _maybe_open(target, 'wb') as fd:
135 _write_file_prefix(fd, interpreter)
136 with zipfile.ZipFile(fd, 'w') as z:
137 root = pathlib.Path(source)
138 for child in root.rglob('*'):
139 arcname = str(child.relative_to(root))
140 z.write(str(child), arcname)
141 if main_py:
142 z.writestr('__main__.py', main_py.encode('utf-8'))
143
Paul Moorea4d4dd32015-03-22 15:32:36 +0000144 if interpreter and not hasattr(target, 'write'):
145 target.chmod(target.stat().st_mode | stat.S_IEXEC)
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400146
147
148def get_interpreter(archive):
149 with _maybe_open(archive, 'rb') as f:
150 if f.read(2) == b'#!':
151 return f.readline().strip().decode(shebang_encoding)
152
153
Paul Moorea4d4dd32015-03-22 15:32:36 +0000154def main(args=None):
155 """Run the zipapp command line interface.
156
157 The ARGS parameter lets you specify the argument list directly.
158 Omitting ARGS (or setting it to None) works as for argparse, using
159 sys.argv[1:] as the argument list.
160 """
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400161 import argparse
162
163 parser = argparse.ArgumentParser()
164 parser.add_argument('--output', '-o', default=None,
165 help="The name of the output archive. "
166 "Required if SOURCE is an archive.")
167 parser.add_argument('--python', '-p', default=None,
168 help="The name of the Python interpreter to use "
169 "(default: no shebang line).")
170 parser.add_argument('--main', '-m', default=None,
171 help="The main function of the application "
172 "(default: use an existing __main__.py).")
173 parser.add_argument('--info', default=False, action='store_true',
174 help="Display the interpreter from the archive.")
175 parser.add_argument('source',
176 help="Source directory (or existing archive).")
177
Paul Moorea4d4dd32015-03-22 15:32:36 +0000178 args = parser.parse_args(args)
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400179
180 # Handle `python -m zipapp archive.pyz --info`.
181 if args.info:
182 if not os.path.isfile(args.source):
183 raise SystemExit("Can only get info for an archive file")
184 interpreter = get_interpreter(args.source)
185 print("Interpreter: {}".format(interpreter or "<none>"))
186 sys.exit(0)
187
188 if os.path.isfile(args.source):
Paul Moorea4d4dd32015-03-22 15:32:36 +0000189 if args.output is None or (os.path.exists(args.output) and
190 os.path.samefile(args.source, args.output)):
Brett Cannoncc4dfc12015-03-13 10:40:49 -0400191 raise SystemExit("In-place editing of archives is not supported")
192 if args.main:
193 raise SystemExit("Cannot change the main function when copying")
194
195 create_archive(args.source, args.output,
196 interpreter=args.python, main=args.main)
197
198
199if __name__ == '__main__':
200 main()