Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a4d4dd3

Browse files
committed
#23657 Don't explicitly do an isinstance check for str in zipapp
As a result, explicitly support pathlib.Path objects as arguments. Also added tests for the CLI interface.
1 parent 67057ab commit a4d4dd3

3 files changed

Lines changed: 138 additions & 17 deletions

File tree

Doc/library/zipapp.rst

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,21 +104,22 @@ The module defines two convenience functions:
104104
Create an application archive from *source*. The source can be any
105105
of the following:
106106

107-
* The name of a directory, in which case a new application archive
108-
will be created from the content of that directory.
109-
* The name of an existing application archive file, in which case the file is
110-
copied to the target (modifying it to reflect the value given for the
111-
*interpreter* argument). The file name should include the ``.pyz``
112-
extension, if required.
107+
* The name of a directory, or a :class:`pathlib.Path` object referring
108+
to a directory, in which case a new application archive will be
109+
created from the content of that directory.
110+
* The name of an existing application archive file, or a :class:`pathlib.Path`
111+
object referring to such a file, in which case the file is copied to
112+
the target (modifying it to reflect the value given for the *interpreter*
113+
argument). The file name should include the ``.pyz`` extension, if required.
113114
* A file object open for reading in bytes mode. The content of the
114115
file should be an application archive, and the file object is
115116
assumed to be positioned at the start of the archive.
116117

117118
The *target* argument determines where the resulting archive will be
118119
written:
119120

120-
* If it is the name of a file, the archive will be written to that
121-
file.
121+
* If it is the name of a file, or a :class:`pathlb.Path` object,
122+
the archive will be written to that file.
122123
* If it is an open file object, the archive will be written to that
123124
file object, which must be open for writing in bytes mode.
124125
* If the target is omitted (or None), the source must be a directory

Lib/test/test_zipapp.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import zipapp
1010
import zipfile
1111

12+
from unittest.mock import patch
1213

1314
class ZipAppTest(unittest.TestCase):
1415

@@ -28,6 +29,15 @@ def test_create_archive(self):
2829
zipapp.create_archive(str(source), str(target))
2930
self.assertTrue(target.is_file())
3031

32+
def test_create_archive_with_pathlib(self):
33+
# Test packing a directory using Path objects for source and target.
34+
source = self.tmpdir / 'source'
35+
source.mkdir()
36+
(source / '__main__.py').touch()
37+
target = self.tmpdir / 'source.pyz'
38+
zipapp.create_archive(source, target)
39+
self.assertTrue(target.is_file())
40+
3141
def test_create_archive_with_subdirs(self):
3242
# Test packing a directory includes entries for subdirectories.
3343
source = self.tmpdir / 'source'
@@ -184,6 +194,18 @@ def test_write_shebang_to_fileobj(self):
184194
zipapp.create_archive(str(target), new_target, interpreter='python2.7')
185195
self.assertTrue(new_target.getvalue().startswith(b'#!python2.7\n'))
186196

197+
def test_read_from_pathobj(self):
198+
# Test that we can copy an archive using an pathlib.Path object
199+
# for the source.
200+
source = self.tmpdir / 'source'
201+
source.mkdir()
202+
(source / '__main__.py').touch()
203+
target1 = self.tmpdir / 'target1.pyz'
204+
target2 = self.tmpdir / 'target2.pyz'
205+
zipapp.create_archive(source, target1, interpreter='python')
206+
zipapp.create_archive(target1, target2, interpreter='python2.7')
207+
self.assertEqual(zipapp.get_interpreter(target2), 'python2.7')
208+
187209
def test_read_from_fileobj(self):
188210
# Test that we can copy an archive using an open file object.
189211
source = self.tmpdir / 'source'
@@ -246,5 +268,82 @@ def test_no_shebang_is_not_executable(self):
246268
self.assertFalse(target.stat().st_mode & stat.S_IEXEC)
247269

248270

271+
class ZipAppCmdlineTest(unittest.TestCase):
272+
273+
"""Test zipapp module command line API."""
274+
275+
def setUp(self):
276+
tmpdir = tempfile.TemporaryDirectory()
277+
self.addCleanup(tmpdir.cleanup)
278+
self.tmpdir = pathlib.Path(tmpdir.name)
279+
280+
def make_archive(self):
281+
# Test that an archive with no shebang line is not made executable.
282+
source = self.tmpdir / 'source'
283+
source.mkdir()
284+
(source / '__main__.py').touch()
285+
target = self.tmpdir / 'source.pyz'
286+
zipapp.create_archive(source, target)
287+
return target
288+
289+
def test_cmdline_create(self):
290+
# Test the basic command line API.
291+
source = self.tmpdir / 'source'
292+
source.mkdir()
293+
(source / '__main__.py').touch()
294+
args = [str(source)]
295+
zipapp.main(args)
296+
target = source.with_suffix('.pyz')
297+
self.assertTrue(target.is_file())
298+
299+
def test_cmdline_copy(self):
300+
# Test copying an archive.
301+
original = self.make_archive()
302+
target = self.tmpdir / 'target.pyz'
303+
args = [str(original), '-o', str(target)]
304+
zipapp.main(args)
305+
self.assertTrue(target.is_file())
306+
307+
def test_cmdline_copy_inplace(self):
308+
# Test copying an archive in place fails.
309+
original = self.make_archive()
310+
target = self.tmpdir / 'target.pyz'
311+
args = [str(original), '-o', str(original)]
312+
with self.assertRaises(SystemExit) as cm:
313+
zipapp.main(args)
314+
# Program should exit with a non-zero returm code.
315+
self.assertTrue(cm.exception.code)
316+
317+
def test_cmdline_copy_change_main(self):
318+
# Test copying an archive doesn't allow changing __main__.py.
319+
original = self.make_archive()
320+
target = self.tmpdir / 'target.pyz'
321+
args = [str(original), '-o', str(target), '-m', 'foo:bar']
322+
with self.assertRaises(SystemExit) as cm:
323+
zipapp.main(args)
324+
# Program should exit with a non-zero returm code.
325+
self.assertTrue(cm.exception.code)
326+
327+
@patch('sys.stdout', new_callable=io.StringIO)
328+
def test_info_command(self, mock_stdout):
329+
# Test the output of the info command.
330+
target = self.make_archive()
331+
args = [str(target), '--info']
332+
with self.assertRaises(SystemExit) as cm:
333+
zipapp.main(args)
334+
# Program should exit with a zero returm code.
335+
self.assertEqual(cm.exception.code, 0)
336+
self.assertEqual(mock_stdout.getvalue(), "Interpreter: <none>\n")
337+
338+
def test_info_error(self):
339+
# Test the info command fails when the archive does not exist.
340+
target = self.tmpdir / 'dummy.pyz'
341+
args = [str(target), '--info']
342+
with self.assertRaises(SystemExit) as cm:
343+
zipapp.main(args)
344+
# Program should exit with a non-zero returm code.
345+
self.assertTrue(cm.exception.code)
346+
347+
249348
if __name__ == "__main__":
250349
unittest.main()

Lib/zipapp.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ class ZipAppError(ValueError):
3636

3737
@contextlib.contextmanager
3838
def _maybe_open(archive, mode):
39+
if isinstance(archive, pathlib.Path):
40+
archive = str(archive)
3941
if isinstance(archive, str):
4042
with open(archive, mode) as f:
4143
yield f
@@ -46,7 +48,7 @@ def _maybe_open(archive, mode):
4648
def _write_file_prefix(f, interpreter):
4749
"""Write a shebang line."""
4850
if interpreter:
49-
shebang = b'#!%b\n' % (interpreter.encode(shebang_encoding),)
51+
shebang = b'#!' + interpreter.encode(shebang_encoding) + b'\n'
5052
f.write(shebang)
5153

5254

@@ -92,12 +94,22 @@ def create_archive(source, target=None, interpreter=None, main=None):
9294
is an error to omit MAIN if the directory has no __main__.py.
9395
"""
9496
# Are we copying an existing archive?
95-
if not (isinstance(source, str) and os.path.isdir(source)):
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:
96106
_copy_archive(source, target, interpreter)
97107
return
98108

99109
# We are creating a new archive from a directory.
100-
has_main = os.path.exists(os.path.join(source, '__main__.py'))
110+
if not source.exists():
111+
raise ZipAppError("Source does not exist")
112+
has_main = (source / '__main__.py').is_file()
101113
if main and has_main:
102114
raise ZipAppError(
103115
"Cannot specify entry point if the source has __main__.py")
@@ -115,7 +127,9 @@ def create_archive(source, target=None, interpreter=None, main=None):
115127
main_py = MAIN_TEMPLATE.format(module=mod, fn=fn)
116128

117129
if target is None:
118-
target = source + '.pyz'
130+
target = source.with_suffix('.pyz')
131+
elif not hasattr(target, 'write'):
132+
target = pathlib.Path(target)
119133

120134
with _maybe_open(target, 'wb') as fd:
121135
_write_file_prefix(fd, interpreter)
@@ -127,8 +141,8 @@ def create_archive(source, target=None, interpreter=None, main=None):
127141
if main_py:
128142
z.writestr('__main__.py', main_py.encode('utf-8'))
129143

130-
if interpreter and isinstance(target, str):
131-
os.chmod(target, os.stat(target).st_mode | stat.S_IEXEC)
144+
if interpreter and not hasattr(target, 'write'):
145+
target.chmod(target.stat().st_mode | stat.S_IEXEC)
132146

133147

134148
def get_interpreter(archive):
@@ -137,7 +151,13 @@ def get_interpreter(archive):
137151
return f.readline().strip().decode(shebang_encoding)
138152

139153

140-
def main():
154+
def 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+
"""
141161
import argparse
142162

143163
parser = argparse.ArgumentParser()
@@ -155,7 +175,7 @@ def main():
155175
parser.add_argument('source',
156176
help="Source directory (or existing archive).")
157177

158-
args = parser.parse_args()
178+
args = parser.parse_args(args)
159179

160180
# Handle `python -m zipapp archive.pyz --info`.
161181
if args.info:
@@ -166,7 +186,8 @@ def main():
166186
sys.exit(0)
167187

168188
if os.path.isfile(args.source):
169-
if args.output is None or os.path.samefile(args.source, args.output):
189+
if args.output is None or (os.path.exists(args.output) and
190+
os.path.samefile(args.source, args.output)):
170191
raise SystemExit("In-place editing of archives is not supported")
171192
if args.main:
172193
raise SystemExit("Cannot change the main function when copying")

0 commit comments

Comments
 (0)