From 8460f8d46479f9387bf17042c00bf1e11c8eb2dc Mon Sep 17 00:00:00 2001 From: yuanx749 Date: Sat, 23 May 2026 14:14:54 +0300 Subject: [PATCH 1/5] Add test --- Lib/test/test_zipapp.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Lib/test/test_zipapp.py b/Lib/test/test_zipapp.py index 8fb0a68deba535..7b68b1b953cd4f 100644 --- a/Lib/test/test_zipapp.py +++ b/Lib/test/test_zipapp.py @@ -1,6 +1,7 @@ """Test harness for the zipapp module.""" import io +import os import pathlib import stat import sys @@ -366,6 +367,38 @@ def test_shebang_is_executable(self): zipapp.create_archive(str(source), str(target), interpreter='python') self.assertTrue(target.stat().st_mode & stat.S_IEXEC) + @unittest.skipIf(sys.platform == 'win32', + 'Windows does not support an executable bit') + @unittest.skipUnless(hasattr(os, 'umask'), 'test needs os.umask()') + @os_helper.skip_unless_working_chmod + def test_shebang_executable_bits_match_readable_bits(self): + source = self.tmpdir / 'source' + source.mkdir() + (source / '__main__.py').touch() + for umask, expected_mode in ((0o022, 0o755), (0o077, 0o700)): + with self.subTest(umask=umask): + target = self.tmpdir / f'source-{umask:o}.pyz' + with os_helper.temp_umask(umask): + zipapp.create_archive(str(source), str(target), interpreter='python') + self.assertEqual(stat.S_IMODE(target.stat().st_mode), expected_mode) + + @unittest.skipIf(sys.platform == 'win32', + 'Windows does not support an executable bit') + @unittest.skipUnless(hasattr(os, 'umask'), 'test needs os.umask()') + @os_helper.skip_unless_working_chmod + def test_copied_shebang_executable_bits_match_readable_bits(self): + source = self.tmpdir / 'source' + source.mkdir() + (source / '__main__.py').touch() + archive = self.tmpdir / 'source.pyz' + zipapp.create_archive(str(source), str(archive)) + for umask, expected_mode in ((0o022, 0o755), (0o077, 0o700)): + with self.subTest(umask=umask): + target = self.tmpdir / f'target-{umask:o}.pyz' + with os_helper.temp_umask(umask): + zipapp.create_archive(str(archive), str(target), interpreter='python') + self.assertEqual(stat.S_IMODE(target.stat().st_mode), expected_mode) + @unittest.skipIf(sys.platform == 'win32', 'Windows does not support an executable bit') def test_no_shebang_is_not_executable(self): From a5e3cb7852f7c8ed2fd4416239714b50f8af7340 Mon Sep 17 00:00:00 2001 From: yuanx749 Date: Sat, 23 May 2026 14:16:43 +0300 Subject: [PATCH 2/5] Set executable bits from readable bits --- Lib/zipapp.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Lib/zipapp.py b/Lib/zipapp.py index a1cef18ada9d05..53b5f4e0517984 100644 --- a/Lib/zipapp.py +++ b/Lib/zipapp.py @@ -50,6 +50,16 @@ def _write_file_prefix(f, interpreter): f.write(shebang) +def _make_executable(path): + mode = os.stat(path).st_mode + executable = ( + (mode & stat.S_IRUSR) >> 2 + | (mode & stat.S_IRGRP) >> 2 + | (mode & stat.S_IROTH) >> 2 + ) + os.chmod(path, mode | executable) + + def _copy_archive(archive, new_archive, interpreter=None): """Copy an application archive, modifying the shebang line.""" with _maybe_open(archive, 'rb') as src: @@ -70,7 +80,7 @@ def _copy_archive(archive, new_archive, interpreter=None): shutil.copyfileobj(src, dst) if interpreter and isinstance(new_archive, str): - os.chmod(new_archive, os.stat(new_archive).st_mode | stat.S_IEXEC) + _make_executable(new_archive) def create_archive(source, target=None, interpreter=None, main=None, @@ -169,7 +179,7 @@ def create_archive(source, target=None, interpreter=None, main=None, z.writestr('__main__.py', main_py.encode('utf-8')) if interpreter and not hasattr(target, 'write'): - target.chmod(target.stat().st_mode | stat.S_IEXEC) + _make_executable(target) def get_interpreter(archive): From 31593820c7c0c3b06166fb535ab43e8608ff5760 Mon Sep 17 00:00:00 2001 From: yuanx749 Date: Tue, 23 Jun 2026 10:14:15 +0300 Subject: [PATCH 3/5] Add news entry --- .../next/Library/2026-06-23-10-14-15.gh-issue-96867._sZyQg.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-06-23-10-14-15.gh-issue-96867._sZyQg.rst diff --git a/Misc/NEWS.d/next/Library/2026-06-23-10-14-15.gh-issue-96867._sZyQg.rst b/Misc/NEWS.d/next/Library/2026-06-23-10-14-15.gh-issue-96867._sZyQg.rst new file mode 100644 index 00000000000000..881f57b0fa7f23 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-23-10-14-15.gh-issue-96867._sZyQg.rst @@ -0,0 +1,2 @@ +Fix :mod:`zipapp` to set executable bits on archives with shebangs based on +their readable permission bits. Contributed by Xiao Yuan. From 127ca4b4d10b52f77d2d19e4a863462844416321 Mon Sep 17 00:00:00 2001 From: yuanx749 Date: Tue, 23 Jun 2026 10:22:20 +0300 Subject: [PATCH 4/5] Add PathLike zipapp test --- Lib/test/test_zipapp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_zipapp.py b/Lib/test/test_zipapp.py index 7b68b1b953cd4f..0e5ee38e7b9be4 100644 --- a/Lib/test/test_zipapp.py +++ b/Lib/test/test_zipapp.py @@ -379,7 +379,7 @@ def test_shebang_executable_bits_match_readable_bits(self): with self.subTest(umask=umask): target = self.tmpdir / f'source-{umask:o}.pyz' with os_helper.temp_umask(umask): - zipapp.create_archive(str(source), str(target), interpreter='python') + zipapp.create_archive(source, target, interpreter='python') self.assertEqual(stat.S_IMODE(target.stat().st_mode), expected_mode) @unittest.skipIf(sys.platform == 'win32', @@ -391,12 +391,12 @@ def test_copied_shebang_executable_bits_match_readable_bits(self): source.mkdir() (source / '__main__.py').touch() archive = self.tmpdir / 'source.pyz' - zipapp.create_archive(str(source), str(archive)) + zipapp.create_archive(source, archive) for umask, expected_mode in ((0o022, 0o755), (0o077, 0o700)): with self.subTest(umask=umask): target = self.tmpdir / f'target-{umask:o}.pyz' with os_helper.temp_umask(umask): - zipapp.create_archive(str(archive), str(target), interpreter='python') + zipapp.create_archive(archive, target, interpreter='python') self.assertEqual(stat.S_IMODE(target.stat().st_mode), expected_mode) @unittest.skipIf(sys.platform == 'win32', From 2de44db7a0086c7884b1f26c321dc26145a0a5ff Mon Sep 17 00:00:00 2001 From: yuanx749 Date: Tue, 23 Jun 2026 10:22:49 +0300 Subject: [PATCH 5/5] Handle PathLike zipapp targets --- Lib/zipapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/zipapp.py b/Lib/zipapp.py index 53b5f4e0517984..73a1467300609e 100644 --- a/Lib/zipapp.py +++ b/Lib/zipapp.py @@ -79,7 +79,7 @@ def _copy_archive(archive, new_archive, interpreter=None): dst.write(first_2) shutil.copyfileobj(src, dst) - if interpreter and isinstance(new_archive, str): + if interpreter and isinstance(new_archive, (str, os.PathLike)): _make_executable(new_archive)