[Python-checkins] cpython: Issue #23491: Implement PEP 441: Improving Python Zip Application Support

brett.cannon python-checkins at python.org
Fri Mar 13 15:42:15 CET 2015


https://hg.python.org/cpython/rev/d1e9f337fea1
changeset:   94979:d1e9f337fea1
user:        Brett Cannon <brett at python.org>
date:        Fri Mar 13 10:40:49 2015 -0400
summary:
  Issue #23491: Implement PEP 441: Improving Python Zip Application Support

Thanks to Paul Moore for the PEP and implementation.

files:
  Doc/library/distribution.rst          |    1 +
  Doc/library/zipapp.rst                |  257 ++++++++++++++
  Doc/whatsnew/3.5.rst                  |   21 +-
  Lib/test/test_zipapp.py               |  250 +++++++++++++
  Lib/zipapp.py                         |  179 +++++++++
  Tools/msi/launcher/launcher_en-US.wxl |    2 +
  Tools/msi/launcher/launcher_reg.wxs   |   14 +
  7 files changed, 720 insertions(+), 4 deletions(-)


diff --git a/Doc/library/distribution.rst b/Doc/library/distribution.rst
--- a/Doc/library/distribution.rst
+++ b/Doc/library/distribution.rst
@@ -12,3 +12,4 @@
    distutils.rst
    ensurepip.rst
    venv.rst
+   zipapp.rst
diff --git a/Doc/library/zipapp.rst b/Doc/library/zipapp.rst
new file mode 100644
--- /dev/null
+++ b/Doc/library/zipapp.rst
@@ -0,0 +1,257 @@
+:mod:`zipapp` --- Manage executable python zip archives
+=======================================================
+
+.. module:: zipapp
+   :synopsis: Manage executable python zip archives
+
+
+.. index::
+   single: Executable Zip Files
+
+.. versionadded:: 3.5
+
+**Source code:** :source:`Lib/zipapp.py`
+
+--------------
+
+This module provides tools to manage the creation of zip files containing
+Python code, which can be  :ref:`executed directly by the Python interpreter
+<using-on-interface-options>`.  The module provides both a
+:ref:`zipapp-command-line-interface` and a :ref:`zipapp-python-api`.
+
+
+Basic Example
+-------------
+
+The following example shows how the :ref:`command-line-interface`
+can be used to create an executable archive from a directory containing
+Python code.  When run, the archive will execute the ``main`` function from
+the module ``myapp`` in the archive.
+
+.. code-block:: sh
+
+   $ python -m zipapp myapp -m "myapp:main"
+   $ python myapp.pyz
+   <output from myapp>
+
+
+.. _zipapp-command-line-interface:
+
+Command-Line Interface
+----------------------
+
+When called as a program from the command line, the following form is used:
+
+.. code-block:: sh
+
+   $ python -m zipapp source [options]
+
+If *source* is a directory, this will create an archive from the contents of
+*source*.  If *source* is a file, it should be an archive, and it will be
+copied to the target archive (or the contents of its shebang line will be
+displayed if the --info option is specified).
+
+The following options are understood:
+
+.. program:: zipapp
+
+.. cmdoption:: -o <output>, --output=<output>
+
+   Write the output to a file named *output*.  If this option is not specified,
+   the output filename will be the same as the input *source*, with the
+   extension ``.pyz`` added.  If an explicit filename is given, it is used as
+   is (so a ``.pyz`` extension should be included if required).
+
+   An output filename must be specified if the *source* is an archive (and in
+   that case, *output* must not be the same as *source*).
+
+.. cmdoption:: -p <interpreter>, --python=<interpreter>
+
+   Add a ``#!`` line to the archive specifying *interpreter* as the command
+   to run.  Also, on POSIX, make the archive executable.  The default is to
+   write no ``#!`` line, and not make the file executable.
+
+.. cmdoption:: -m <mainfn>, --main=<mainfn>
+
+   Write a ``__main__.py`` file to the archive that executes *mainfn*.  The
+   *mainfn* argument should have the form "pkg.mod:fn", where "pkg.mod" is a
+   package/module in the archive, and "fn" is a callable in the given module.
+   The ``__main__.py`` file will execute that callable.
+
+   :option:`--main` cannot be specified when copying an archive.
+
+.. cmdoption:: --info
+
+   Display the interpreter embedded in the archive, for diagnostic purposes.  In
+   this case, any other options are ignored and SOURCE must be an archive, not a
+   directory.
+
+.. cmdoption:: -h, --help
+
+   Print a short usage message and exit.
+
+
+.. _zipapp-python-api:
+
+Python API
+----------
+
+The module defines two convenience functions:
+
+
+.. function:: create_archive(source, target=None, interpreter=None, main=None)
+
+   Create an application archive from *source*.  The source can be any
+   of the following:
+   
+   * The name of a directory, in which case a new application archive
+     will be created from the content of that directory.
+   * The name of an existing application archive file, in which case the file is
+     copied to the target (modifying it to reflect the value given for the
+     *interpreter* argument).  The file name should include the ``.pyz``
+     extension, if required.
+   * A file object open for reading in bytes mode.  The content of the
+     file should be an application archive, and the file object is
+     assumed to be positioned at the start of the archive.
+   
+   The *target* argument determines where the resulting archive will be
+   written:
+   
+   * If it is the name of a file, the archive will be written to that
+     file.
+   * If it is an open file object, the archive will be written to that
+     file object, which must be open for writing in bytes mode.
+   * If the target is omitted (or None), the source must be a directory
+     and the target will be a file with the same name as the source, with
+     a ``.pyz`` extension added.
+   
+   The *interpreter* argument specifies the name of the Python
+   interpreter with which the archive will be executed.  It is written as
+   a "shebang" line at the start of the archive.  On POSIX, this will be
+   interpreted by the OS, and on Windows it will be handled by the Python
+   launcher.  Omitting the *interpreter* results in no shebang line being
+   written.  If an interpreter is specified, and the target is a
+   filename, the executable bit of the target file will be set.
+   
+   The *main* argument specifies the name of a callable which will be
+   used as the main program for the archive.  It can only be specified if
+   the source is a directory, and the source does not already contain a
+   ``__main__.py`` file.  The *main* argument should take the form
+   "pkg.module:callable" and the archive will be run by importing
+   "pkg.module" and executing the given callable with no arguments.  It
+   is an error to omit *main* if the source is a directory and does not
+   contain a ``__main__.py`` file, as otherwise the resulting archive
+   would not be executable.
+   
+   If a file object is specified for *source* or *target*, it is the
+   caller's responsibility to close it after calling create_archive.
+   
+   When copying an existing archive, file objects supplied only need
+   ``read`` and ``readline``, or ``write`` methods.  When creating an
+   archive from a directory, if the target is a file object it will be
+   passed to the ``zipfile.ZipFile`` class, and must supply the methods
+   needed by that class.
+
+.. function:: get_interpreter(archive)
+
+   Return the interpreter specified in the ``#!`` line at the start of the
+   archive.  If there is no ``#!`` line, return :const:`None`.
+   The *archive* argument can be a filename or a file-like object open
+   for reading in bytes mode.  It is assumed to be at the start of the archive.
+
+
+.. _zipapp-examples:
+
+Examples
+--------
+
+Pack up a directory into an archive, and run it.
+
+.. code-block:: sh
+
+   $ python -m zipapp myapp
+   $ python myapp.pyz
+   <output from myapp>
+
+The same can be done using the :func:`create_archive` functon::
+
+   >>> import zipapp
+   >>> zipapp.create_archive('myapp.pyz', 'myapp')
+
+To make the application directly executable on POSIX, specify an interpreter
+to use.
+
+.. code-block:: sh
+
+   $ python -m zipapp myapp -p "/usr/bin/env python"
+   $ ./myapp.pyz
+   <output from myapp>
+
+To replace the shebang line on an existing archive, create a modified archive
+using the :func:`create_archive` function::
+
+   >>> import zipapp
+   >>> zipapp.create_archive('old_archive.pyz', 'new_archive.pyz', '/usr/bin/python3')
+
+To update the file in place, do the replacement in memory using a :class:`BytesIO`
+object, and then overwrite the source afterwards.  Note that there is a risk
+when overwriting a file in place that an error will result in the loss of
+the original file.  This code does not protect against such errors, but
+production code should do so.  Also, this method will only work if the archive
+fits in memory::
+
+   >>> import zipapp
+   >>> import io
+   >>> temp = io.BytesIO()
+   >>> zipapp.create_archive('myapp.pyz', temp, '/usr/bin/python2')
+   >>> with open('myapp.pyz', 'wb') as f:
+   >>>     f.write(temp.getvalue())
+
+Note that if you specify an interpreter and then distribute your application
+archive, you need to ensure that the interpreter used is portable.  The Python
+launcher for Windows supports most common forms of POSIX ``#!`` line, but there
+are other issues to consider:
+
+* If you use "/usr/bin/env python" (or other forms of the "python" command,
+  such as "/usr/bin/python"), you need to consider that your users may have
+  either Python 2 or Python 3 as their default, and write your code to work
+  under both versions.
+* If you use an explicit version, for example "/usr/bin/env python3" your
+  application will not work for users who do not have that version.  (This
+  may be what you want if you have not made your code Python 2 compatible).
+* There is no way to say "python X.Y or later", so be careful of using an
+  exact version like "/usr/bin/env python3.4" as you will need to change your
+  shebang line for users of Python 3.5, for example.
+
+The Python Zip Application Archive Format
+-----------------------------------------
+
+Python has been able to execute zip files which contain a ``__main__.py`` file
+since version 2.6.  In order to be executed by Python, an application archive
+simply has to be a standard zip file containing a ``__main__.py`` file which
+will be run as the entry point for the application.  As usual for any Python
+script, the parent of the script (in this case the zip file) will be placed on
+:data:`sys.path` and thus further modules can be imported from the zip file.
+
+The zip file format allows arbitrary data to be prepended to a zip file.  The
+zip application format uses this ability to prepend a standard POSIX "shebang"
+line to the file (``#!/path/to/interpreter``).
+
+Formally, the Python zip application format is therefore:
+
+1. An optional shebang line, containing the characters ``b'#!'`` followed by an
+   interpreter name, and then a newline (``b'\n'``) character.  The interpreter
+   name can be anything acceptable to the OS "shebang" processing, or the Python
+   launcher on Windows.  The interpreter should be encoded in UTF-8 on Windows,
+   and in :func:`sys.getfilesystemencoding()` on POSIX.
+2. Standard zipfile data, as generated by the :mod:`zipfile` module.  The
+   zipfile content *must* include a file called ``__main__.py`` (which must be
+   in the "root" of the zipfile - i.e., it cannot be in a subdirectory).  The
+   zipfile data can be compressed or uncompressed.
+
+If an application archive has a shebang line, it may have the executable bit set
+on POSIX systems, to allow it to be executed directly.
+
+There is no requirement that the tools in this module are used to create
+application archives - the module is a convenience, but archives in the above
+format created by any means are acceptable to Python.
diff --git a/Doc/whatsnew/3.5.rst b/Doc/whatsnew/3.5.rst
--- a/Doc/whatsnew/3.5.rst
+++ b/Doc/whatsnew/3.5.rst
@@ -71,7 +71,8 @@
 
 New library modules:
 
-* None yet.
+* :mod:`zipapp`: :ref:`Improving Python ZIP Application Support
+  <whatsnew-zipapp>` (:pep:`441`).
 
 New built-in features:
 
@@ -166,10 +167,22 @@
 New Modules
 ===========
 
-.. module name
-.. -----------
+.. _whatsnew-zipapp:
 
-* None yet.
+zipapp
+------
+
+The new :mod:`zipapp` module (specified in :pep:`441`) provides an API and
+command line tool for creating executable Python Zip Applications, which
+were introduced in Python 2.6 in :issue:`1739468` but which were not well
+publicised, either at the time or since.
+
+With the new module, bundling your application is as simple as putting all
+the files, including a ``__main__.py`` file, into a directory ``myapp``
+and running::
+
+    $ python -m zipapp myapp
+    $ python myapp.pyz
 
 
 Improved Modules
diff --git a/Lib/test/test_zipapp.py b/Lib/test/test_zipapp.py
new file mode 100644
--- /dev/null
+++ b/Lib/test/test_zipapp.py
@@ -0,0 +1,250 @@
+"""Test harness for the zipapp module."""
+
+import io
+import pathlib
+import stat
+import sys
+import tempfile
+import unittest
+import zipapp
+import zipfile
+
+
+class ZipAppTest(unittest.TestCase):
+
+    """Test zipapp module functionality."""
+
+    def setUp(self):
+        tmpdir = tempfile.TemporaryDirectory()
+        self.addCleanup(tmpdir.cleanup)
+        self.tmpdir = pathlib.Path(tmpdir.name)
+
+    def test_create_archive(self):
+        # Test packing a directory.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target))
+        self.assertTrue(target.is_file())
+
+    def test_create_archive_with_subdirs(self):
+        # Test packing a directory includes entries for subdirectories.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        (source / 'foo').mkdir()
+        (source / 'bar').mkdir()
+        (source / 'foo' / '__init__.py').touch()
+        target = io.BytesIO()
+        zipapp.create_archive(str(source), target)
+        target.seek(0)
+        with zipfile.ZipFile(target, 'r') as z:
+            self.assertIn('foo/', z.namelist())
+            self.assertIn('bar/', z.namelist())
+
+    def test_create_archive_default_target(self):
+        # Test packing a directory to the default name.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        zipapp.create_archive(str(source))
+        expected_target = self.tmpdir / 'source.pyz'
+        self.assertTrue(expected_target.is_file())
+
+    def test_no_main(self):
+        # Test that packing a directory with no __main__.py fails.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / 'foo.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        with self.assertRaises(zipapp.ZipAppError):
+            zipapp.create_archive(str(source), str(target))
+
+    def test_main_and_main_py(self):
+        # Test that supplying a main argument with __main__.py fails.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        with self.assertRaises(zipapp.ZipAppError):
+            zipapp.create_archive(str(source), str(target), main='pkg.mod:fn')
+
+    def test_main_written(self):
+        # Test that the __main__.py is written correctly.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / 'foo.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target), main='pkg.mod:fn')
+        with zipfile.ZipFile(str(target), 'r') as z:
+            self.assertIn('__main__.py', z.namelist())
+            self.assertIn(b'pkg.mod.fn()', z.read('__main__.py'))
+
+    def test_main_only_written_once(self):
+        # Test that we don't write multiple __main__.py files.
+        # The initial implementation had this bug; zip files allow
+        # multiple entries with the same name
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        # Write 2 files, as the original bug wrote __main__.py
+        # once for each file written :-(
+        # See http://bugs.python.org/review/23491/diff/13982/Lib/zipapp.py#newcode67Lib/zipapp.py:67
+        # (line 67)
+        (source / 'foo.py').touch()
+        (source / 'bar.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target), main='pkg.mod:fn')
+        with zipfile.ZipFile(str(target), 'r') as z:
+            self.assertEqual(1, z.namelist().count('__main__.py'))
+
+    def test_main_validation(self):
+        # Test that invalid values for main are rejected.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        target = self.tmpdir / 'source.pyz'
+        problems = [
+            '', 'foo', 'foo:', ':bar', '12:bar', 'a.b.c.:d',
+            '.a:b', 'a:b.', 'a:.b', 'a:silly name'
+        ]
+        for main in problems:
+            with self.subTest(main=main):
+                with self.assertRaises(zipapp.ZipAppError):
+                    zipapp.create_archive(str(source), str(target), main=main)
+
+    def test_default_no_shebang(self):
+        # Test that no shebang line is written to the target by default.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target))
+        with target.open('rb') as f:
+            self.assertNotEqual(f.read(2), b'#!')
+
+    def test_custom_interpreter(self):
+        # Test that a shebang line with a custom interpreter is written
+        # correctly.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target), interpreter='python')
+        with target.open('rb') as f:
+            self.assertEqual(f.read(2), b'#!')
+            self.assertEqual(b'python\n', f.readline())
+
+    def test_pack_to_fileobj(self):
+        # Test that we can pack to a file object.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = io.BytesIO()
+        zipapp.create_archive(str(source), target, interpreter='python')
+        self.assertTrue(target.getvalue().startswith(b'#!python\n'))
+
+    def test_read_shebang(self):
+        # Test that we can read the shebang line correctly.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target), interpreter='python')
+        self.assertEqual(zipapp.get_interpreter(str(target)), 'python')
+
+    def test_read_missing_shebang(self):
+        # Test that reading the shebang line of a file without one returns None.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target))
+        self.assertEqual(zipapp.get_interpreter(str(target)), None)
+
+    def test_modify_shebang(self):
+        # Test that we can change the shebang of a file.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target), interpreter='python')
+        new_target = self.tmpdir / 'changed.pyz'
+        zipapp.create_archive(str(target), str(new_target), interpreter='python2.7')
+        self.assertEqual(zipapp.get_interpreter(str(new_target)), 'python2.7')
+
+    def test_write_shebang_to_fileobj(self):
+        # Test that we can change the shebang of a file, writing the result to a
+        # file object.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target), interpreter='python')
+        new_target = io.BytesIO()
+        zipapp.create_archive(str(target), new_target, interpreter='python2.7')
+        self.assertTrue(new_target.getvalue().startswith(b'#!python2.7\n'))
+
+    def test_read_from_fileobj(self):
+        # Test that we can copy an archive using an open file object.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        temp_archive = io.BytesIO()
+        zipapp.create_archive(str(source), temp_archive, interpreter='python')
+        new_target = io.BytesIO()
+        temp_archive.seek(0)
+        zipapp.create_archive(temp_archive, new_target, interpreter='python2.7')
+        self.assertTrue(new_target.getvalue().startswith(b'#!python2.7\n'))
+
+    def test_remove_shebang(self):
+        # Test that we can remove the shebang from a file.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target), interpreter='python')
+        new_target = self.tmpdir / 'changed.pyz'
+        zipapp.create_archive(str(target), str(new_target), interpreter=None)
+        self.assertEqual(zipapp.get_interpreter(str(new_target)), None)
+
+    def test_content_of_copied_archive(self):
+        # Test that copying an archive doesn't corrupt it.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = io.BytesIO()
+        zipapp.create_archive(str(source), target, interpreter='python')
+        new_target = io.BytesIO()
+        target.seek(0)
+        zipapp.create_archive(target, new_target, interpreter=None)
+        new_target.seek(0)
+        with zipfile.ZipFile(new_target, 'r') as z:
+            self.assertEqual(set(z.namelist()), {'__main__.py'})
+
+    # (Unix only) tests that archives with shebang lines are made executable
+    @unittest.skipIf(sys.platform == 'win32',
+                     'Windows does not support an executable bit')
+    def test_shebang_is_executable(self):
+        # Test that an archive with a shebang line is made executable.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        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')
+    def test_no_shebang_is_not_executable(self):
+        # Test that an archive with no shebang line is not made executable.
+        source = self.tmpdir / 'source'
+        source.mkdir()
+        (source / '__main__.py').touch()
+        target = self.tmpdir / 'source.pyz'
+        zipapp.create_archive(str(source), str(target), interpreter=None)
+        self.assertFalse(target.stat().st_mode & stat.S_IEXEC)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/Lib/zipapp.py b/Lib/zipapp.py
new file mode 100644
--- /dev/null
+++ b/Lib/zipapp.py
@@ -0,0 +1,179 @@
+import contextlib
+import os
+import pathlib
+import shutil
+import stat
+import sys
+import zipfile
+
+__all__ = ['ZipAppError', 'create_archive', 'get_interpreter']
+
+
+# The __main__.py used if the users specifies "-m module:fn".
+# Note that this will always be written as UTF-8 (module and
+# function names can be non-ASCII in Python 3).
+# We add a coding cookie even though UTF-8 is the default in Python 3
+# because the resulting archive may be intended to be run under Python 2.
+MAIN_TEMPLATE = """\
+# -*- coding: utf-8 -*-
+import {module}
+{module}.{fn}()
+"""
+
+
+# The Windows launcher defaults to UTF-8 when parsing shebang lines if the
+# file has no BOM. So use UTF-8 on Windows.
+# On Unix, use the filesystem encoding.
+if sys.platform.startswith('win'):
+    shebang_encoding = 'utf-8'
+else:
+    shebang_encoding = sys.getfilesystemencoding()
+
+
+class ZipAppError(ValueError):
+    pass
+
+
+ at contextlib.contextmanager
+def _maybe_open(archive, mode):
+    if isinstance(archive, str):
+        with open(archive, mode) as f:
+            yield f
+    else:
+        yield archive
+
+
+def _write_file_prefix(f, interpreter):
+    """Write a shebang line."""
+    if interpreter:
+        shebang = b'#!%b\n' % (interpreter.encode(shebang_encoding),)
+        f.write(shebang)
+
+
+def _copy_archive(archive, new_archive, interpreter=None):
+    """Copy an application archive, modifying the shebang line."""
+    with _maybe_open(archive, 'rb') as src:
+        # Skip the shebang line from the source.
+        # Read 2 bytes of the source and check if they are #!.
+        first_2 = src.read(2)
+        if first_2 == b'#!':
+            # Discard the initial 2 bytes and the rest of the shebang line.
+            first_2 = b''
+            src.readline()
+
+        with _maybe_open(new_archive, 'wb') as dst:
+            _write_file_prefix(dst, interpreter)
+            # If there was no shebang, "first_2" contains the first 2 bytes
+            # of the source file, so write them before copying the rest
+            # of the file.
+            dst.write(first_2)
+            shutil.copyfileobj(src, dst)
+
+    if interpreter and isinstance(new_archive, str):
+        os.chmod(new_archive, os.stat(new_archive).st_mode | stat.S_IEXEC)
+
+
+def create_archive(source, target=None, interpreter=None, main=None):
+    """Create an application archive from SOURCE.
+
+    The SOURCE can be the name of a directory, or a filename or a file-like
+    object referring to an existing archive.
+
+    The content of SOURCE is packed into an application archive in TARGET,
+    which can be a filename or a file-like object.  If SOURCE is a directory,
+    TARGET can be omitted and will default to the name of SOURCE with .pyz
+    appended.
+
+    The created application archive will have a shebang line specifying
+    that it should run with INTERPRETER (there will be no shebang line if
+    INTERPRETER is None), and a __main__.py which runs MAIN (if MAIN is
+    not specified, an existing __main__.py will be used). It is an to specify
+    MAIN for anything other than a directory source with no __main__.py, and it
+    is an error to omit MAIN if the directory has no __main__.py.
+    """
+    # Are we copying an existing archive?
+    if not (isinstance(source, str) and os.path.isdir(source)):
+        _copy_archive(source, target, interpreter)
+        return
+
+    # We are creating a new archive from a directory
+    has_main = os.path.exists(os.path.join(source, '__main__.py'))
+    if main and has_main:
+        raise ZipAppError(
+            "Cannot specify entry point if the source has __main__.py")
+    if not (main or has_main):
+        raise ZipAppError("Archive has no entry point")
+
+    main_py = None
+    if main:
+        # Check that main has the right format
+        mod, sep, fn = main.partition(':')
+        mod_ok = all(part.isidentifier() for part in mod.split('.'))
+        fn_ok = all(part.isidentifier() for part in fn.split('.'))
+        if not (sep == ':' and mod_ok and fn_ok):
+            raise ZipAppError("Invalid entry point: " + main)
+        main_py = MAIN_TEMPLATE.format(module=mod, fn=fn)
+
+    if target is None:
+        target = source + '.pyz'
+
+    with _maybe_open(target, 'wb') as fd:
+        _write_file_prefix(fd, interpreter)
+        with zipfile.ZipFile(fd, 'w') as z:
+            root = pathlib.Path(source)
+            for child in root.rglob('*'):
+                arcname = str(child.relative_to(root))
+                z.write(str(child), arcname)
+            if main_py:
+                z.writestr('__main__.py', main_py.encode('utf-8'))
+
+    if interpreter and isinstance(target, str):
+        os.chmod(target, os.stat(target).st_mode | stat.S_IEXEC)
+
+
+def get_interpreter(archive):
+    with _maybe_open(archive, 'rb') as f:
+        if f.read(2) == b'#!':
+            return f.readline().strip().decode(shebang_encoding)
+
+
+def main():
+    import argparse
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--output', '-o', default=None,
+            help="The name of the output archive. "
+                 "Required if SOURCE is an archive.")
+    parser.add_argument('--python', '-p', default=None,
+            help="The name of the Python interpreter to use "
+                 "(default: no shebang line).")
+    parser.add_argument('--main', '-m', default=None,
+            help="The main function of the application "
+                 "(default: use an existing __main__.py).")
+    parser.add_argument('--info', default=False, action='store_true',
+            help="Display the interpreter from the archive.")
+    parser.add_argument('source',
+            help="Source directory (or existing archive).")
+
+    args = parser.parse_args()
+
+    # Handle `python -m zipapp archive.pyz --info`.
+    if args.info:
+        if not os.path.isfile(args.source):
+            raise SystemExit("Can only get info for an archive file")
+        interpreter = get_interpreter(args.source)
+        print("Interpreter: {}".format(interpreter or "<none>"))
+        sys.exit(0)
+
+    if os.path.isfile(args.source):
+        if args.output is None or os.path.samefile(args.source, args.output):
+            raise SystemExit("In-place editing of archives is not supported")
+        if args.main:
+            raise SystemExit("Cannot change the main function when copying")
+
+    create_archive(args.source, args.output,
+                   interpreter=args.python, main=args.main)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/Tools/msi/launcher/launcher_en-US.wxl b/Tools/msi/launcher/launcher_en-US.wxl
--- a/Tools/msi/launcher/launcher_en-US.wxl
+++ b/Tools/msi/launcher/launcher_en-US.wxl
@@ -5,4 +5,6 @@
     <String Id="PythonFileDescription">Python File</String>
     <String Id="PythonNoConFileDescription">Python File (no console)</String>
     <String Id="PythonCompiledFileDescription">Compiled Python File</String>
+    <String Id="PythonArchiveFileDescription">Python Zip Application File</String>
+    <String Id="PythonNoConArchiveFileDescription">Python Zip Application File (no console)</String>
 </WixLocalization>
diff --git a/Tools/msi/launcher/launcher_reg.wxs b/Tools/msi/launcher/launcher_reg.wxs
--- a/Tools/msi/launcher/launcher_reg.wxs
+++ b/Tools/msi/launcher/launcher_reg.wxs
@@ -26,6 +26,20 @@
                     <Extension Id="$(var.FileExtension)o" />
                 </ProgId>
                 <RegistryValue Root="HKCR" Key="$(var.TestPrefix)Python.CompiledFile\shellex\DropHandler" Value="{60254CA5-953B-11CF-8C96-00AA00B8708C}" Type="string" />
+
+                <ProgId Id="$(var.TestPrefix)Python.ArchiveFile" Description="!(loc.PythonArchiveFileDescription)" Advertise="no" Icon="py.exe" IconIndex="1">
+                    <Extension Id="$(var.ArchiveFileExtension)" ContentType="application/x-zip-compressed">
+                        <Verb Id="open" TargetFile="py.exe" Argument=""%L" %*" />
+                    </Extension>
+                </ProgId>
+                <RegistryValue Root="HKCR" Key="$(var.TestPrefix)Python.ArchiveFile\shellex\DropHandler" Value="{60254CA5-953B-11CF-8C96-00AA00B8708C}" Type="string" />
+                
+                <ProgId Id="$(var.TestPrefix)Python.NoConArchiveFile" Description="!(loc.PythonNoConArchiveFileDescription)" Advertise="no" Icon="py.exe" IconIndex="1">
+                    <Extension Id="$(var.ArchiveFileExtension)w" ContentType="application/x-zip-compressed">
+                        <Verb Id="open" TargetFile="pyw.exe" Argument=""%L" %*" />
+                    </Extension>
+                </ProgId>
+                <RegistryValue Root="HKCR" Key="$(var.TestPrefix)Python.NoConArchiveFile\shellex\DropHandler" Value="{60254CA5-953B-11CF-8C96-00AA00B8708C}" Type="string" />
             </Component>
         </ComponentGroup>
     </Fragment>

-- 
Repository URL: https://hg.python.org/cpython


More information about the Python-checkins mailing list