1"""Test harness for the zipapp module.""" 2 3import io 4import pathlib 5import stat 6import sys 7import tempfile 8import unittest 9import zipapp 10import zipfile 11from test.support import requires_zlib 12 13from unittest.mock import patch 14 15class ZipAppTest(unittest.TestCase): 16 17 """Test zipapp module functionality.""" 18 19 def setUp(self): 20 tmpdir = tempfile.TemporaryDirectory() 21 self.addCleanup(tmpdir.cleanup) 22 self.tmpdir = pathlib.Path(tmpdir.name) 23 24 def test_create_archive(self): 25 # Test packing a directory. 26 source = self.tmpdir / 'source' 27 source.mkdir() 28 (source / '__main__.py').touch() 29 target = self.tmpdir / 'source.pyz' 30 zipapp.create_archive(str(source), str(target)) 31 self.assertTrue(target.is_file()) 32 33 def test_create_archive_with_pathlib(self): 34 # Test packing a directory using Path objects for source and target. 35 source = self.tmpdir / 'source' 36 source.mkdir() 37 (source / '__main__.py').touch() 38 target = self.tmpdir / 'source.pyz' 39 zipapp.create_archive(source, target) 40 self.assertTrue(target.is_file()) 41 42 def test_create_archive_with_subdirs(self): 43 # Test packing a directory includes entries for subdirectories. 44 source = self.tmpdir / 'source' 45 source.mkdir() 46 (source / '__main__.py').touch() 47 (source / 'foo').mkdir() 48 (source / 'bar').mkdir() 49 (source / 'foo' / '__init__.py').touch() 50 target = io.BytesIO() 51 zipapp.create_archive(str(source), target) 52 target.seek(0) 53 with zipfile.ZipFile(target, 'r') as z: 54 self.assertIn('foo/', z.namelist()) 55 self.assertIn('bar/', z.namelist()) 56 57 def test_create_archive_with_filter(self): 58 # Test packing a directory and using filter to specify 59 # which files to include. 60 def skip_pyc_files(path): 61 return path.suffix != '.pyc' 62 source = self.tmpdir / 'source' 63 source.mkdir() 64 (source / '__main__.py').touch() 65 (source / 'test.py').touch() 66 (source / 'test.pyc').touch() 67 target = self.tmpdir / 'source.pyz' 68 69 zipapp.create_archive(source, target, filter=skip_pyc_files) 70 with zipfile.ZipFile(target, 'r') as z: 71 self.assertIn('__main__.py', z.namelist()) 72 self.assertIn('test.py', z.namelist()) 73 self.assertNotIn('test.pyc', z.namelist()) 74 75 def test_create_archive_filter_exclude_dir(self): 76 # Test packing a directory and using a filter to exclude a 77 # subdirectory (ensures that the path supplied to include 78 # is relative to the source location, as expected). 79 def skip_dummy_dir(path): 80 return path.parts[0] != 'dummy' 81 source = self.tmpdir / 'source' 82 source.mkdir() 83 (source / '__main__.py').touch() 84 (source / 'test.py').touch() 85 (source / 'dummy').mkdir() 86 (source / 'dummy' / 'test2.py').touch() 87 target = self.tmpdir / 'source.pyz' 88 89 zipapp.create_archive(source, target, filter=skip_dummy_dir) 90 with zipfile.ZipFile(target, 'r') as z: 91 self.assertEqual(len(z.namelist()), 2) 92 self.assertIn('__main__.py', z.namelist()) 93 self.assertIn('test.py', z.namelist()) 94 95 def test_create_archive_default_target(self): 96 # Test packing a directory to the default name. 97 source = self.tmpdir / 'source' 98 source.mkdir() 99 (source / '__main__.py').touch() 100 zipapp.create_archive(str(source)) 101 expected_target = self.tmpdir / 'source.pyz' 102 self.assertTrue(expected_target.is_file()) 103 104 @requires_zlib 105 def test_create_archive_with_compression(self): 106 # Test packing a directory into a compressed archive. 107 source = self.tmpdir / 'source' 108 source.mkdir() 109 (source / '__main__.py').touch() 110 (source / 'test.py').touch() 111 target = self.tmpdir / 'source.pyz' 112 113 zipapp.create_archive(source, target, compressed=True) 114 with zipfile.ZipFile(target, 'r') as z: 115 for name in ('__main__.py', 'test.py'): 116 self.assertEqual(z.getinfo(name).compress_type, 117 zipfile.ZIP_DEFLATED) 118 119 def test_no_main(self): 120 # Test that packing a directory with no __main__.py fails. 121 source = self.tmpdir / 'source' 122 source.mkdir() 123 (source / 'foo.py').touch() 124 target = self.tmpdir / 'source.pyz' 125 with self.assertRaises(zipapp.ZipAppError): 126 zipapp.create_archive(str(source), str(target)) 127 128 def test_main_and_main_py(self): 129 # Test that supplying a main argument with __main__.py fails. 130 source = self.tmpdir / 'source' 131 source.mkdir() 132 (source / '__main__.py').touch() 133 target = self.tmpdir / 'source.pyz' 134 with self.assertRaises(zipapp.ZipAppError): 135 zipapp.create_archive(str(source), str(target), main='pkg.mod:fn') 136 137 def test_main_written(self): 138 # Test that the __main__.py is written correctly. 139 source = self.tmpdir / 'source' 140 source.mkdir() 141 (source / 'foo.py').touch() 142 target = self.tmpdir / 'source.pyz' 143 zipapp.create_archive(str(source), str(target), main='pkg.mod:fn') 144 with zipfile.ZipFile(str(target), 'r') as z: 145 self.assertIn('__main__.py', z.namelist()) 146 self.assertIn(b'pkg.mod.fn()', z.read('__main__.py')) 147 148 def test_main_only_written_once(self): 149 # Test that we don't write multiple __main__.py files. 150 # The initial implementation had this bug; zip files allow 151 # multiple entries with the same name 152 source = self.tmpdir / 'source' 153 source.mkdir() 154 # Write 2 files, as the original bug wrote __main__.py 155 # once for each file written :-( 156 # See http://bugs.python.org/review/23491/diff/13982/Lib/zipapp.py#newcode67Lib/zipapp.py:67 157 # (line 67) 158 (source / 'foo.py').touch() 159 (source / 'bar.py').touch() 160 target = self.tmpdir / 'source.pyz' 161 zipapp.create_archive(str(source), str(target), main='pkg.mod:fn') 162 with zipfile.ZipFile(str(target), 'r') as z: 163 self.assertEqual(1, z.namelist().count('__main__.py')) 164 165 def test_main_validation(self): 166 # Test that invalid values for main are rejected. 167 source = self.tmpdir / 'source' 168 source.mkdir() 169 target = self.tmpdir / 'source.pyz' 170 problems = [ 171 '', 'foo', 'foo:', ':bar', '12:bar', 'a.b.c.:d', 172 '.a:b', 'a:b.', 'a:.b', 'a:silly name' 173 ] 174 for main in problems: 175 with self.subTest(main=main): 176 with self.assertRaises(zipapp.ZipAppError): 177 zipapp.create_archive(str(source), str(target), main=main) 178 179 def test_default_no_shebang(self): 180 # Test that no shebang line is written to the target by default. 181 source = self.tmpdir / 'source' 182 source.mkdir() 183 (source / '__main__.py').touch() 184 target = self.tmpdir / 'source.pyz' 185 zipapp.create_archive(str(source), str(target)) 186 with target.open('rb') as f: 187 self.assertNotEqual(f.read(2), b'#!') 188 189 def test_custom_interpreter(self): 190 # Test that a shebang line with a custom interpreter is written 191 # correctly. 192 source = self.tmpdir / 'source' 193 source.mkdir() 194 (source / '__main__.py').touch() 195 target = self.tmpdir / 'source.pyz' 196 zipapp.create_archive(str(source), str(target), interpreter='python') 197 with target.open('rb') as f: 198 self.assertEqual(f.read(2), b'#!') 199 self.assertEqual(b'python\n', f.readline()) 200 201 def test_pack_to_fileobj(self): 202 # Test that we can pack to a file object. 203 source = self.tmpdir / 'source' 204 source.mkdir() 205 (source / '__main__.py').touch() 206 target = io.BytesIO() 207 zipapp.create_archive(str(source), target, interpreter='python') 208 self.assertTrue(target.getvalue().startswith(b'#!python\n')) 209 210 def test_read_shebang(self): 211 # Test that we can read the shebang line correctly. 212 source = self.tmpdir / 'source' 213 source.mkdir() 214 (source / '__main__.py').touch() 215 target = self.tmpdir / 'source.pyz' 216 zipapp.create_archive(str(source), str(target), interpreter='python') 217 self.assertEqual(zipapp.get_interpreter(str(target)), 'python') 218 219 def test_read_missing_shebang(self): 220 # Test that reading the shebang line of a file without one returns None. 221 source = self.tmpdir / 'source' 222 source.mkdir() 223 (source / '__main__.py').touch() 224 target = self.tmpdir / 'source.pyz' 225 zipapp.create_archive(str(source), str(target)) 226 self.assertEqual(zipapp.get_interpreter(str(target)), None) 227 228 def test_modify_shebang(self): 229 # Test that we can change the shebang of a file. 230 source = self.tmpdir / 'source' 231 source.mkdir() 232 (source / '__main__.py').touch() 233 target = self.tmpdir / 'source.pyz' 234 zipapp.create_archive(str(source), str(target), interpreter='python') 235 new_target = self.tmpdir / 'changed.pyz' 236 zipapp.create_archive(str(target), str(new_target), interpreter='python2.7') 237 self.assertEqual(zipapp.get_interpreter(str(new_target)), 'python2.7') 238 239 def test_write_shebang_to_fileobj(self): 240 # Test that we can change the shebang of a file, writing the result to a 241 # file object. 242 source = self.tmpdir / 'source' 243 source.mkdir() 244 (source / '__main__.py').touch() 245 target = self.tmpdir / 'source.pyz' 246 zipapp.create_archive(str(source), str(target), interpreter='python') 247 new_target = io.BytesIO() 248 zipapp.create_archive(str(target), new_target, interpreter='python2.7') 249 self.assertTrue(new_target.getvalue().startswith(b'#!python2.7\n')) 250 251 def test_read_from_pathobj(self): 252 # Test that we can copy an archive using a pathlib.Path object 253 # for the source. 254 source = self.tmpdir / 'source' 255 source.mkdir() 256 (source / '__main__.py').touch() 257 target1 = self.tmpdir / 'target1.pyz' 258 target2 = self.tmpdir / 'target2.pyz' 259 zipapp.create_archive(source, target1, interpreter='python') 260 zipapp.create_archive(target1, target2, interpreter='python2.7') 261 self.assertEqual(zipapp.get_interpreter(target2), 'python2.7') 262 263 def test_read_from_fileobj(self): 264 # Test that we can copy an archive using an open file object. 265 source = self.tmpdir / 'source' 266 source.mkdir() 267 (source / '__main__.py').touch() 268 target = self.tmpdir / 'source.pyz' 269 temp_archive = io.BytesIO() 270 zipapp.create_archive(str(source), temp_archive, interpreter='python') 271 new_target = io.BytesIO() 272 temp_archive.seek(0) 273 zipapp.create_archive(temp_archive, new_target, interpreter='python2.7') 274 self.assertTrue(new_target.getvalue().startswith(b'#!python2.7\n')) 275 276 def test_remove_shebang(self): 277 # Test that we can remove the shebang from a file. 278 source = self.tmpdir / 'source' 279 source.mkdir() 280 (source / '__main__.py').touch() 281 target = self.tmpdir / 'source.pyz' 282 zipapp.create_archive(str(source), str(target), interpreter='python') 283 new_target = self.tmpdir / 'changed.pyz' 284 zipapp.create_archive(str(target), str(new_target), interpreter=None) 285 self.assertEqual(zipapp.get_interpreter(str(new_target)), None) 286 287 def test_content_of_copied_archive(self): 288 # Test that copying an archive doesn't corrupt it. 289 source = self.tmpdir / 'source' 290 source.mkdir() 291 (source / '__main__.py').touch() 292 target = io.BytesIO() 293 zipapp.create_archive(str(source), target, interpreter='python') 294 new_target = io.BytesIO() 295 target.seek(0) 296 zipapp.create_archive(target, new_target, interpreter=None) 297 new_target.seek(0) 298 with zipfile.ZipFile(new_target, 'r') as z: 299 self.assertEqual(set(z.namelist()), {'__main__.py'}) 300 301 # (Unix only) tests that archives with shebang lines are made executable 302 @unittest.skipIf(sys.platform == 'win32', 303 'Windows does not support an executable bit') 304 def test_shebang_is_executable(self): 305 # Test that an archive with a shebang line is made executable. 306 source = self.tmpdir / 'source' 307 source.mkdir() 308 (source / '__main__.py').touch() 309 target = self.tmpdir / 'source.pyz' 310 zipapp.create_archive(str(source), str(target), interpreter='python') 311 self.assertTrue(target.stat().st_mode & stat.S_IEXEC) 312 313 @unittest.skipIf(sys.platform == 'win32', 314 'Windows does not support an executable bit') 315 def test_no_shebang_is_not_executable(self): 316 # Test that an archive with no shebang line is not made executable. 317 source = self.tmpdir / 'source' 318 source.mkdir() 319 (source / '__main__.py').touch() 320 target = self.tmpdir / 'source.pyz' 321 zipapp.create_archive(str(source), str(target), interpreter=None) 322 self.assertFalse(target.stat().st_mode & stat.S_IEXEC) 323 324 325class ZipAppCmdlineTest(unittest.TestCase): 326 327 """Test zipapp module command line API.""" 328 329 def setUp(self): 330 tmpdir = tempfile.TemporaryDirectory() 331 self.addCleanup(tmpdir.cleanup) 332 self.tmpdir = pathlib.Path(tmpdir.name) 333 334 def make_archive(self): 335 # Test that an archive with no shebang line is not made executable. 336 source = self.tmpdir / 'source' 337 source.mkdir() 338 (source / '__main__.py').touch() 339 target = self.tmpdir / 'source.pyz' 340 zipapp.create_archive(source, target) 341 return target 342 343 def test_cmdline_create(self): 344 # Test the basic command line API. 345 source = self.tmpdir / 'source' 346 source.mkdir() 347 (source / '__main__.py').touch() 348 args = [str(source)] 349 zipapp.main(args) 350 target = source.with_suffix('.pyz') 351 self.assertTrue(target.is_file()) 352 353 def test_cmdline_copy(self): 354 # Test copying an archive. 355 original = self.make_archive() 356 target = self.tmpdir / 'target.pyz' 357 args = [str(original), '-o', str(target)] 358 zipapp.main(args) 359 self.assertTrue(target.is_file()) 360 361 def test_cmdline_copy_inplace(self): 362 # Test copying an archive in place fails. 363 original = self.make_archive() 364 target = self.tmpdir / 'target.pyz' 365 args = [str(original), '-o', str(original)] 366 with self.assertRaises(SystemExit) as cm: 367 zipapp.main(args) 368 # Program should exit with a non-zero return code. 369 self.assertTrue(cm.exception.code) 370 371 def test_cmdline_copy_change_main(self): 372 # Test copying an archive doesn't allow changing __main__.py. 373 original = self.make_archive() 374 target = self.tmpdir / 'target.pyz' 375 args = [str(original), '-o', str(target), '-m', 'foo:bar'] 376 with self.assertRaises(SystemExit) as cm: 377 zipapp.main(args) 378 # Program should exit with a non-zero return code. 379 self.assertTrue(cm.exception.code) 380 381 @patch('sys.stdout', new_callable=io.StringIO) 382 def test_info_command(self, mock_stdout): 383 # Test the output of the info command. 384 target = self.make_archive() 385 args = [str(target), '--info'] 386 with self.assertRaises(SystemExit) as cm: 387 zipapp.main(args) 388 # Program should exit with a zero return code. 389 self.assertEqual(cm.exception.code, 0) 390 self.assertEqual(mock_stdout.getvalue(), "Interpreter: <none>\n") 391 392 def test_info_error(self): 393 # Test the info command fails when the archive does not exist. 394 target = self.tmpdir / 'dummy.pyz' 395 args = [str(target), '--info'] 396 with self.assertRaises(SystemExit) as cm: 397 zipapp.main(args) 398 # Program should exit with a non-zero return code. 399 self.assertTrue(cm.exception.code) 400 401 402if __name__ == "__main__": 403 unittest.main() 404