Browse Source

Merge pull request #60 from takluyver/wheels

Support for downloading wheels from PyPI
Thomas Kluyver 9 years ago
parent
commit
a0602c103d

+ 1 - 1
.travis.yml

@@ -8,7 +8,7 @@ python:
 script: nosetests
 script: nosetests
 # Ensure dependencies are installed
 # Ensure dependencies are installed
 install:
 install:
-  - pip install requests jinja2
+  - pip install requests requests_download jinja2 yarg
   - if [[ ${TRAVIS_PYTHON_VERSION} == '2.7' ]]; then pip install configparser pathlib; fi
   - if [[ ${TRAVIS_PYTHON_VERSION} == '2.7' ]]; then pip install configparser pathlib; fi
   - if [[ ${TRAVIS_PYTHON_VERSION} == '3.3' ]]; then pip install pathlib; fi
   - if [[ ${TRAVIS_PYTHON_VERSION} == '3.3' ]]; then pip install pathlib; fi
 
 

+ 1 - 1
appveyor.yml

@@ -6,7 +6,7 @@ environment:
 
 
 install:
 install:
   - cinst nsis
   - cinst nsis
-  - "%PYTHON%\\python.exe -m pip install requests jinja2 nose"
+  - "%PYTHON%\\python.exe -m pip install requests requests_download jinja2 yarg nose"
 
 
 build: off
 build: off
 
 

+ 8 - 0
doc/cfgfile.rst

@@ -173,6 +173,14 @@ the line with the key:
    A list of importable package and module names to include in the installer.
    A list of importable package and module names to include in the installer.
    Specify only top-level packages, i.e. without a ``.`` in the name.
    Specify only top-level packages, i.e. without a ``.`` in the name.
 
 
+.. describe:: pypi_wheels (optional)
+
+   A list of packages to download from PyPI, in the format ``name==version``.
+   These must be available as wheels; Pynsist will not try to download sdists
+   or eggs.
+
+   .. versionadded:: 1.7
+
 .. describe:: files (optional)
 .. describe:: files (optional)
 
 
    Extra files or directories to be installed with your application.
    Extra files or directories to be installed with your application.

+ 8 - 0
doc/releasenotes.rst

@@ -1,6 +1,14 @@
 Release notes
 Release notes
 =============
 =============
 
 
+Version 1.7
+-----------
+
+* Support for downloading packages as wheels from PyPI, and new
+  `PyQt5 <https://github.com/takluyver/pynsist/tree/master/examples/pyqt>`__ and
+  `Pyglet <https://github.com/takluyver/pynsist/tree/master/examples/pyglet>`__
+  examples which use this feature.
+
 Version 1.6
 Version 1.6
 -----------
 -----------
 
 

+ 12 - 0
examples/pyglet/README.md

@@ -0,0 +1,12 @@
+This is an example program that accompanies pyglet (http://www.pyglet.org).
+To build the installer, run:
+
+    pynsist installer.cfg
+
+The source code is licensed under the BSD license, which is quite permissive
+(see the source header for details).
+
+The tennis ball image is public domain from openclipart:
+https://openclipart.org/detail/139615/tennis-ball
+
+The sound effect was made using www.bfxr.net, and is released to the public domain.

+ 13 - 0
examples/pyglet/installer.cfg

@@ -0,0 +1,13 @@
+[Application]
+name=Noisy (Pyglet)
+version=1.0
+entry_point=noisy:main
+
+[Python]
+version=3.5.1
+bitness=64
+format=bundled
+
+[Include]
+packages=noisy
+pypi_wheels= pyglet==1.2.4

+ 116 - 0
examples/pyglet/noisy/__init__.py

@@ -0,0 +1,116 @@
+#!/usr/bin/env python
+# ----------------------------------------------------------------------------
+# pyglet
+# Copyright (c) 2006-2008 Alex Holkner
+# All rights reserved.
+# 
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions 
+# are met:
+#
+#  * Redistributions of source code must retain the above copyright
+#    notice, this list of conditions and the following disclaimer.
+#  * Redistributions in binary form must reproduce the above copyright 
+#    notice, this list of conditions and the following disclaimer in
+#    the documentation and/or other materials provided with the
+#    distribution.
+#  * Neither the name of pyglet nor the names of its
+#    contributors may be used to endorse or promote products
+#    derived from this software without specific prior written
+#    permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+# ----------------------------------------------------------------------------
+
+'''Bounces balls around a window and plays noises.
+
+This is a simple demonstration of how pyglet efficiently manages many sound
+channels without intervention.
+'''
+
+import os
+import random
+import sys
+
+from pyglet.gl import *
+import pyglet
+from pyglet.window import key
+
+pyglet.resource.path.insert(0, os.path.dirname(__file__))
+pyglet.resource.reindex()
+BALL_IMAGE = 'ball.png'
+BALL_SOUND = 'ping.wav'
+
+sound = pyglet.resource.media(BALL_SOUND, streaming=False)
+
+class Ball(pyglet.sprite.Sprite):
+    ball_image = pyglet.resource.image(BALL_IMAGE)
+    width = ball_image.width
+    height = ball_image.height
+
+    def __init__(self):
+        x = random.random() * (window.width - self.width)
+        y = random.random() * (window.height - self.height)
+
+        super(Ball, self).__init__(self.ball_image, x, y, batch=balls_batch)
+
+        self.dx = (random.random() - 0.5) * 1000
+        self.dy = (random.random() - 0.5) * 1000
+
+    def update(self, dt):
+        if self.x <= 0 or self.x + self.width >= window.width:
+            self.dx *= -1
+            sound.play()
+        if self.y <= 0 or self.y + self.height >= window.height:
+            self.dy *= -1
+            sound.play()
+        self.x += self.dx * dt
+        self.y += self.dy * dt
+
+        self.x = min(max(self.x, 0), window.width - self.width)
+        self.y = min(max(self.y, 0), window.height - self.height)
+
+window = pyglet.window.Window(640, 480, caption='Noisy')
+
+@window.event
+def on_key_press(symbol, modifiers):
+    if symbol == key.SPACE:
+        balls.append(Ball())
+    elif symbol == key.BACKSPACE:
+        if balls:
+            del balls[-1]
+    elif symbol == key.ESCAPE:
+        window.has_exit = True
+
+@window.event
+def on_draw():
+    window.clear()
+    balls_batch.draw()
+    label.draw()
+
+def update(dt):
+    for ball in balls:
+        ball.update(dt)
+
+balls_batch = pyglet.graphics.Batch()
+label = pyglet.text.Label('Press space to add a ball, backspace to remove',
+                          font_size=14,
+                          x=window.width // 2, y=10, 
+                          anchor_x='center')
+
+balls = []
+
+def main():
+    pyglet.clock.schedule_interval(update, 1/30.)
+    pyglet.app.run()

+ 2 - 0
examples/pyglet/noisy/__main__.py

@@ -0,0 +1,2 @@
+from . import main
+main()

BIN
examples/pyglet/noisy/ball.png


BIN
examples/pyglet/noisy/ping.wav


+ 0 - 0
examples/pyqt/README.md → examples/pyqt4/README.md


+ 0 - 0
examples/pyqt/fetch_pyqt_windows.sh → examples/pyqt4/fetch_pyqt_windows.sh


+ 1 - 1
examples/pyqt/installer.cfg → examples/pyqt4/installer.cfg

@@ -10,4 +10,4 @@ version=3.3.5
 packages=listapp
 packages=listapp
     PyQt4
     PyQt4
     sip
     sip
-files = README
+files = README.md

+ 0 - 0
examples/pyqt/listapp/__init__.py → examples/pyqt4/listapp/__init__.py


+ 0 - 0
examples/pyqt/listapp/main.py → examples/pyqt4/listapp/main.py


+ 0 - 0
examples/pyqt/listapp/main.ui → examples/pyqt4/listapp/main.ui


+ 14 - 0
examples/pyqt5/installer.cfg

@@ -0,0 +1,14 @@
+[Application]
+name=List App (PyQt5)
+version=1.0
+entry_point=listapp:main
+
+[Python]
+version=3.5.1
+bitness=64
+format=bundled
+
+[Include]
+packages=listapp
+pypi_wheels= PyQt5==5.6
+    sip==4.18

+ 33 - 0
examples/pyqt5/listapp/__init__.py

@@ -0,0 +1,33 @@
+import sys
+from PyQt5 import QtWidgets
+
+from .main import Ui_MainWindow
+
+class Main(QtWidgets.QMainWindow):
+    def __init__(self):
+        super().__init__()
+        self.ui = Ui_MainWindow()
+        self.ui.setupUi(self)
+        
+        self.ui.add_button.clicked.connect(self.add_item)
+
+    def get_radio_option(self):
+        if self.ui.radio_1.isChecked():
+            return 'Thing 1'
+        elif self.ui.radio_2.isChecked():
+            return 'Thing 2'
+        elif self.ui.radio_3.isChecked():
+            return 'Thing 3'
+        elif self.ui.radio_4.isChecked():
+            return 'Last thing'
+        return 'No thing'
+        
+    def add_item(self):
+        text = self.get_radio_option()
+        QtWidgets.QListWidgetItem(text, self.ui.listWidget)        
+
+def main():
+    app = QtWidgets.QApplication(sys.argv)
+    window = Main()
+    window.show()
+    sys.exit(app.exec_())

+ 81 - 0
examples/pyqt5/listapp/main.py

@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+
+# Form implementation generated from reading ui file 'main.ui'
+#
+# Created by: PyQt5 UI code generator 5.5.1
+#
+# WARNING! All changes made in this file will be lost!
+
+from PyQt5 import QtCore, QtGui, QtWidgets
+
+class Ui_MainWindow(object):
+    def setupUi(self, MainWindow):
+        MainWindow.setObjectName("MainWindow")
+        MainWindow.resize(393, 606)
+        self.centralwidget = QtWidgets.QWidget(MainWindow)
+        self.centralwidget.setObjectName("centralwidget")
+        self.horizontalLayout = QtWidgets.QHBoxLayout(self.centralwidget)
+        self.horizontalLayout.setObjectName("horizontalLayout")
+        self.groupBox = QtWidgets.QGroupBox(self.centralwidget)
+        self.groupBox.setObjectName("groupBox")
+        self.verticalLayout = QtWidgets.QVBoxLayout(self.groupBox)
+        self.verticalLayout.setObjectName("verticalLayout")
+        self.radio_1 = QtWidgets.QRadioButton(self.groupBox)
+        self.radio_1.setObjectName("radio_1")
+        self.verticalLayout.addWidget(self.radio_1)
+        self.radio_2 = QtWidgets.QRadioButton(self.groupBox)
+        self.radio_2.setObjectName("radio_2")
+        self.verticalLayout.addWidget(self.radio_2)
+        self.radio_3 = QtWidgets.QRadioButton(self.groupBox)
+        self.radio_3.setObjectName("radio_3")
+        self.verticalLayout.addWidget(self.radio_3)
+        self.radio_4 = QtWidgets.QRadioButton(self.groupBox)
+        self.radio_4.setObjectName("radio_4")
+        self.verticalLayout.addWidget(self.radio_4)
+        self.add_button = QtWidgets.QPushButton(self.groupBox)
+        self.add_button.setObjectName("add_button")
+        self.verticalLayout.addWidget(self.add_button)
+        spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
+        self.verticalLayout.addItem(spacerItem)
+        self.horizontalLayout.addWidget(self.groupBox)
+        self.listWidget = QtWidgets.QListWidget(self.centralwidget)
+        self.listWidget.setObjectName("listWidget")
+        self.horizontalLayout.addWidget(self.listWidget)
+        MainWindow.setCentralWidget(self.centralwidget)
+        self.menubar = QtWidgets.QMenuBar(MainWindow)
+        self.menubar.setGeometry(QtCore.QRect(0, 0, 393, 24))
+        self.menubar.setObjectName("menubar")
+        self.menuFile = QtWidgets.QMenu(self.menubar)
+        self.menuFile.setObjectName("menuFile")
+        MainWindow.setMenuBar(self.menubar)
+        self.statusbar = QtWidgets.QStatusBar(MainWindow)
+        self.statusbar.setObjectName("statusbar")
+        MainWindow.setStatusBar(self.statusbar)
+        self.actionNew = QtWidgets.QAction(MainWindow)
+        self.actionNew.setObjectName("actionNew")
+        self.actionOpen = QtWidgets.QAction(MainWindow)
+        self.actionOpen.setObjectName("actionOpen")
+        self.actionSave = QtWidgets.QAction(MainWindow)
+        self.actionSave.setObjectName("actionSave")
+        self.menuFile.addAction(self.actionNew)
+        self.menuFile.addAction(self.actionOpen)
+        self.menuFile.addAction(self.actionSave)
+        self.menubar.addAction(self.menuFile.menuAction())
+
+        self.retranslateUi(MainWindow)
+        QtCore.QMetaObject.connectSlotsByName(MainWindow)
+
+    def retranslateUi(self, MainWindow):
+        _translate = QtCore.QCoreApplication.translate
+        MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
+        self.groupBox.setTitle(_translate("MainWindow", "Click things"))
+        self.radio_1.setText(_translate("MainWindow", "Thing 1"))
+        self.radio_2.setText(_translate("MainWindow", "Thing 2"))
+        self.radio_3.setText(_translate("MainWindow", "Thing 3"))
+        self.radio_4.setText(_translate("MainWindow", "Last thing"))
+        self.add_button.setText(_translate("MainWindow", "Add to list"))
+        self.menuFile.setTitle(_translate("MainWindow", "File"))
+        self.actionNew.setText(_translate("MainWindow", "New"))
+        self.actionOpen.setText(_translate("MainWindow", "Open"))
+        self.actionSave.setText(_translate("MainWindow", "Save"))
+

+ 118 - 0
examples/pyqt5/listapp/main.ui

@@ -0,0 +1,118 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MainWindow</class>
+ <widget class="QMainWindow" name="MainWindow">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>393</width>
+    <height>606</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>MainWindow</string>
+  </property>
+  <widget class="QWidget" name="centralwidget">
+   <layout class="QHBoxLayout" name="horizontalLayout">
+    <item>
+     <widget class="QGroupBox" name="groupBox">
+      <property name="title">
+       <string>Click things</string>
+      </property>
+      <layout class="QVBoxLayout" name="verticalLayout">
+       <item>
+        <widget class="QRadioButton" name="radio_1">
+         <property name="text">
+          <string>Thing 1</string>
+         </property>
+        </widget>
+       </item>
+       <item>
+        <widget class="QRadioButton" name="radio_2">
+         <property name="text">
+          <string>Thing 2</string>
+         </property>
+        </widget>
+       </item>
+       <item>
+        <widget class="QRadioButton" name="radio_3">
+         <property name="text">
+          <string>Thing 3</string>
+         </property>
+        </widget>
+       </item>
+       <item>
+        <widget class="QRadioButton" name="radio_4">
+         <property name="text">
+          <string>Last thing</string>
+         </property>
+        </widget>
+       </item>
+       <item>
+        <widget class="QPushButton" name="add_button">
+         <property name="text">
+          <string>Add to list</string>
+         </property>
+        </widget>
+       </item>
+       <item>
+        <spacer name="verticalSpacer">
+         <property name="orientation">
+          <enum>Qt::Vertical</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>20</width>
+           <height>40</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+      </layout>
+     </widget>
+    </item>
+    <item>
+     <widget class="QListWidget" name="listWidget"/>
+    </item>
+   </layout>
+  </widget>
+  <widget class="QMenuBar" name="menubar">
+   <property name="geometry">
+    <rect>
+     <x>0</x>
+     <y>0</y>
+     <width>393</width>
+     <height>24</height>
+    </rect>
+   </property>
+   <widget class="QMenu" name="menuFile">
+    <property name="title">
+     <string>File</string>
+    </property>
+    <addaction name="actionNew"/>
+    <addaction name="actionOpen"/>
+    <addaction name="actionSave"/>
+   </widget>
+   <addaction name="menuFile"/>
+  </widget>
+  <widget class="QStatusBar" name="statusbar"/>
+  <action name="actionNew">
+   <property name="text">
+    <string>New</string>
+   </property>
+  </action>
+  <action name="actionOpen">
+   <property name="text">
+    <string>Open</string>
+   </property>
+  </action>
+  <action name="actionSave">
+   <property name="text">
+    <string>Save</string>
+   </property>
+  </action>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>

+ 2 - 0
flit.ini

@@ -6,7 +6,9 @@ dist-name = pynsist
 home-page = http://pynsist.readthedocs.org/en/latest/
 home-page = http://pynsist.readthedocs.org/en/latest/
 description-file = README.rst
 description-file = README.rst
 requires = requests
 requires = requests
+    requests_download
     jinja2
     jinja2
+    yarg
     configparser; python_version == '2.7'
     configparser; python_version == '2.7'
     pathlib; python_version == '2.7' or python_version == '3.3'
     pathlib; python_version == '2.7' or python_version == '3.3'
 classifiers = License :: OSI Approved :: MIT License
 classifiers = License :: OSI Approved :: MIT License

+ 14 - 1
nsist/__init__.py

@@ -25,6 +25,7 @@ else:
 
 
 from .copymodules import copy_modules
 from .copymodules import copy_modules
 from .nsiswriter import NSISFileWriter
 from .nsiswriter import NSISFileWriter
+from .pypi import fetch_pypi_wheels
 from .util import download, text_types, get_cache_dir
 from .util import download, text_types, get_cache_dir
 
 
 __version__ = '1.6'
 __version__ = '1.6'
@@ -73,7 +74,9 @@ class InstallerBuilder(object):
             in the config file
             in the config file
     :param str icon: Path to an icon for the application
     :param str icon: Path to an icon for the application
     :param list packages: List of strings for importable packages to include
     :param list packages: List of strings for importable packages to include
+    :param list pypi_reqs: Package specifications to fetch from PyPI as wheels
     :param list extra_files: List of 2-tuples (file, destination) of files to include
     :param list extra_files: List of 2-tuples (file, destination) of files to include
+    :param list exclude: Paths of files to exclude that would otherwise be included
     :param str py_version: Full version of Python to bundle
     :param str py_version: Full version of Python to bundle
     :param int py_bitness: Bitness of bundled Python (32 or 64)
     :param int py_bitness: Bitness of bundled Python (32 or 64)
     :param str build_dir: Directory to run the build in
     :param str build_dir: Directory to run the build in
@@ -85,7 +88,7 @@ class InstallerBuilder(object):
                 py_bitness=DEFAULT_BITNESS, py_format='installer',
                 py_bitness=DEFAULT_BITNESS, py_format='installer',
                 build_dir=DEFAULT_BUILD_DIR,
                 build_dir=DEFAULT_BUILD_DIR,
                 installer_name=None, nsi_template=None,
                 installer_name=None, nsi_template=None,
-                exclude=None):
+                exclude=None, pypi_wheel_reqs=None):
         self.appname = appname
         self.appname = appname
         self.version = version
         self.version = version
         self.shortcuts = shortcuts
         self.shortcuts = shortcuts
@@ -93,6 +96,7 @@ class InstallerBuilder(object):
         self.packages = packages or []
         self.packages = packages or []
         self.exclude = [os.path.normpath(p) for p in (exclude or [])]
         self.exclude = [os.path.normpath(p) for p in (exclude or [])]
         self.extra_files = extra_files or []
         self.extra_files = extra_files or []
+        self.pypi_wheel_reqs = pypi_wheel_reqs or []
 
 
         # Python options
         # Python options
         self.py_version = py_version
         self.py_version = py_version
@@ -321,10 +325,18 @@ if __name__ == '__main__':
         build_pkg_dir = pjoin(self.build_dir, 'pkgs')
         build_pkg_dir = pjoin(self.build_dir, 'pkgs')
         if os.path.isdir(build_pkg_dir):
         if os.path.isdir(build_pkg_dir):
             shutil.rmtree(build_pkg_dir)
             shutil.rmtree(build_pkg_dir)
+
+        # 1. Manually prepared packages
         if os.path.isdir('pynsist_pkgs'):
         if os.path.isdir('pynsist_pkgs'):
             shutil.copytree('pynsist_pkgs', build_pkg_dir)
             shutil.copytree('pynsist_pkgs', build_pkg_dir)
         else:
         else:
             os.mkdir(build_pkg_dir)
             os.mkdir(build_pkg_dir)
+
+        # 2. Wheels from PyPI
+        fetch_pypi_wheels(self.pypi_wheel_reqs, build_pkg_dir,
+                          py_version=self.py_version, bitness=self.py_bitness)
+
+        # 3. Copy importable modules
         copy_modules(self.packages, build_pkg_dir,
         copy_modules(self.packages, build_pkg_dir,
                      py_version=self.py_version, exclude=self.exclude)
                      py_version=self.py_version, exclude=self.exclude)
 
 
@@ -479,6 +491,7 @@ def main(argv=None):
             icon = appcfg.get('icon', DEFAULT_ICON),
             icon = appcfg.get('icon', DEFAULT_ICON),
             shortcuts = shortcuts,
             shortcuts = shortcuts,
             packages = cfg.get('Include', 'packages', fallback='').splitlines(),
             packages = cfg.get('Include', 'packages', fallback='').splitlines(),
+            pypi_wheel_reqs = cfg.get('Include', 'pypi_wheels', fallback='').splitlines(),
             extra_files = configreader.read_extra_files(cfg),
             extra_files = configreader.read_extra_files(cfg),
             py_version = cfg.get('Python', 'version', fallback=DEFAULT_PY_VERSION),
             py_version = cfg.get('Python', 'version', fallback=DEFAULT_PY_VERSION),
             py_bitness = cfg.getint('Python', 'bitness', fallback=DEFAULT_BITNESS),
             py_bitness = cfg.getint('Python', 'bitness', fallback=DEFAULT_BITNESS),

+ 1 - 0
nsist/configreader.py

@@ -69,6 +69,7 @@ CONFIG_VALIDATORS = {
     ]),
     ]),
     'Include': SectionValidator([
     'Include': SectionValidator([
         ('packages', False),
         ('packages', False),
+        ('pypi_wheels', False),
         ('files', False),
         ('files', False),
         ('exclude', False),
         ('exclude', False),
     ]),
     ]),

+ 151 - 0
nsist/pypi.py

@@ -0,0 +1,151 @@
+from distutils.version import LooseVersion
+import errno
+import hashlib
+import logging
+import re
+import zipfile
+
+import yarg
+from requests_download import download, HashTracker
+
+from .util import get_cache_dir
+
+logger = logging.getLogger(__name__)
+
+def find_pypi_release(requirement):
+    if '==' in requirement:
+        name, version = requirement.split('==', 1)
+        return yarg.get(name).release(version)
+    else:
+        return yarg.get(requirement).latest_release
+
+class NoWheelError(Exception): pass
+
+class WheelDownloader(object):
+    def __init__(self, requirement, py_version, bitness):
+        self.requirement = requirement
+        self.py_version = py_version
+        self.bitness = bitness
+
+        if requirement.count('==') != 1:
+            raise ValueError("Requirement {!r} did not match name==version")
+        self.name, self.version = requirement.split('==', 1)
+
+    def score_platform(self, platform):
+        target = 'win_amd64' if self.bitness == 64 else 'win32'
+        d = {target: 2, 'any': 1}
+        return max(d.get(p, 0) for p in platform.split('.'))
+
+    def score_abi(self, abi):
+        # Are there other valid options here?
+        d = {'abi3': 2, 'none': 1}
+        return max(d.get(a, 0) for a in abi.split('.'))
+
+    def score_interpreter(self, interpreter):
+        py_version_nodot = ''.join(self.py_version.split('.')[:2])
+        py_version_major = self.py_version.split('.')[0]
+        d = {'cp'+py_version_nodot: 4,
+             'cp'+py_version_major: 3,
+             'py'+py_version_nodot: 2,
+             'py'+py_version_major: 1
+            }
+        return max(d.get(i, 0) for i in interpreter.split('.'))
+
+    def pick_best_wheel(self, release_list):
+        best_score = (0, 0, 0)
+        best = None
+        for release in release_list:
+            if release.package_type != 'wheel':
+                continue
+
+            m = re.search(r'-([^-]+)-([^-]+)-([^-]+)\.whl', release.filename)
+            if not m:
+                continue
+
+            interpreter, abi, platform = m.group(1, 2, 3)
+            score = (self.score_platform(platform),
+                     self.score_abi(abi),
+                     self.score_interpreter(interpreter)
+                    )
+            if any(s==0 for s in score):
+                # Incompatible
+                continue
+
+            if score > best_score:
+                best = release
+                best_score = score
+
+        return best
+
+    def check_cache(self):
+        dist_dir = get_cache_dir() / 'pypi' / self.name
+        if not dist_dir.is_dir():
+            return None
+
+        if self.version:
+            release_dir = dist_dir / self.version
+        else:
+            versions = [p.name for p in dist_dir.iterdir()]
+            if not versions:
+                return None
+            latest = max(versions, key=LooseVersion)
+            release_dir = dist_dir / latest
+
+        rel = self.pick_best_wheel(CachedRelease(p.name)
+                                   for p in release_dir.iterdir())
+        if rel is None:
+            return None
+        return release_dir / rel.filename
+
+    def fetch(self):
+        p = self.check_cache()
+        if p is not None:
+            return p
+
+        release_list = yarg.get(self.name).release(self.version)
+        preferred_release = self.pick_best_wheel(release_list)
+        if preferred_release is None:
+            raise NoWheelError('No compatible wheels found for {0.name} {0.version}'.format(self))
+
+        download_to = get_cache_dir() / 'pypi' / self.name / self.version
+        try:
+            download_to.mkdir(parents=True)
+        except OSError as e:
+            # Py2 compatible equivalent of FileExistsError
+            if e.errno != errno.EEXIST:
+                raise
+        target = download_to / preferred_release.filename
+
+        from . import __version__
+        hasher = HashTracker(hashlib.md5())
+        headers = {'user-agent': 'pynsist/'+__version__}
+        logger.info('Downloading wheel: %s', preferred_release.url)
+        download(preferred_release.url, str(target), headers=headers,
+                 trackers=(hasher,))
+        if hasher.hashobj.hexdigest() != preferred_release.md5_digest:
+            target.unlink()
+            raise ValueError('Downloaded wheel corrupted: {}'.format(preferred_release.url))
+
+        return target
+
+
+class CachedRelease(object):
+    # Mock enough of the yarg Release object to be compatible with
+    # pick_best_release above
+    def __init__(self, filename):
+        self.filename = filename
+        self.package_type = 'wheel' if filename.endswith('.whl') else ''
+
+def extract_wheel(whl_file, target_dir):
+    with zipfile.ZipFile(str(whl_file), mode='r') as zf:
+        names = zf.namelist()
+        # TODO: Do anything with data and dist-info folders?
+        pkg_files = [n for n in names \
+                     if not n.split('/')[0].endswith(('.data', '.dist-info'))]
+        zf.extractall(target_dir, members=pkg_files)
+
+def fetch_pypi_wheels(requirements, target_dir, py_version, bitness):
+    for req in requirements:
+        wd = WheelDownloader(req, py_version, bitness)
+        whl_file = wd.fetch()
+        extract_wheel(whl_file, target_dir)