Issue #23491: Implement PEP 441: Improving Python Zip Application Support

Thanks to Paul Moore for the PEP and implementation.
This commit is contained in:
Brett Cannon 2015-03-13 10:40:49 -04:00
parent ff2a661ef0
commit cc4dfc1b75
7 changed files with 720 additions and 4 deletions

View file

@ -12,3 +12,4 @@ with a local index server, or without any index server at all.
distutils.rst
ensurepip.rst
venv.rst
zipapp.rst

257
Doc/library/zipapp.rst Normal file
View file

@ -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.

View file

@ -71,7 +71,8 @@ New syntax features:
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 @@ Some smaller changes made to the core Python language are:
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

250
Lib/test/test_zipapp.py Normal file
View file

@ -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()

179
Lib/zipapp.py Normal file
View file

@ -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()

View file

@ -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>

View file

@ -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="&quot;%L&quot; %*" />
</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="&quot;%L&quot; %*" />
</Extension>
</ProgId>
<RegistryValue Root="HKCR" Key="$(var.TestPrefix)Python.NoConArchiveFile\shellex\DropHandler" Value="{60254CA5-953B-11CF-8C96-00AA00B8708C}" Type="string" />
</Component>
</ComponentGroup>
</Fragment>