Issue #23491: Implement PEP 441: Improving Python Zip Application Support
Thanks to Paul Moore for the PEP and implementation.
This commit is contained in:
parent
ff2a661ef0
commit
cc4dfc1b75
|
@ -12,3 +12,4 @@ with a local index server, or without any index server at all.
|
||||||
distutils.rst
|
distutils.rst
|
||||||
ensurepip.rst
|
ensurepip.rst
|
||||||
venv.rst
|
venv.rst
|
||||||
|
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.
|
|
@ -71,7 +71,8 @@ New syntax features:
|
||||||
|
|
||||||
New library modules:
|
New library modules:
|
||||||
|
|
||||||
* None yet.
|
* :mod:`zipapp`: :ref:`Improving Python ZIP Application Support
|
||||||
|
<whatsnew-zipapp>` (:pep:`441`).
|
||||||
|
|
||||||
New built-in features:
|
New built-in features:
|
||||||
|
|
||||||
|
@ -166,10 +167,22 @@ Some smaller changes made to the core Python language are:
|
||||||
New Modules
|
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
|
Improved Modules
|
||||||
|
|
|
@ -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()
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
|
@ -5,4 +5,6 @@
|
||||||
<String Id="PythonFileDescription">Python File</String>
|
<String Id="PythonFileDescription">Python File</String>
|
||||||
<String Id="PythonNoConFileDescription">Python File (no console)</String>
|
<String Id="PythonNoConFileDescription">Python File (no console)</String>
|
||||||
<String Id="PythonCompiledFileDescription">Compiled Python File</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>
|
</WixLocalization>
|
||||||
|
|
|
@ -26,6 +26,20 @@
|
||||||
<Extension Id="$(var.FileExtension)o" />
|
<Extension Id="$(var.FileExtension)o" />
|
||||||
</ProgId>
|
</ProgId>
|
||||||
<RegistryValue Root="HKCR" Key="$(var.TestPrefix)Python.CompiledFile\shellex\DropHandler" Value="{60254CA5-953B-11CF-8C96-00AA00B8708C}" Type="string" />
|
<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>
|
</Component>
|
||||||
</ComponentGroup>
|
</ComponentGroup>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
Loading…
Reference in New Issue