1
0
Эх сурвалжийг харах

Allow finding wheels in local directories

Thomas Kluyver 7 жил өмнө
parent
commit
206ebdbdf1

+ 15 - 5
doc/cfgfile.rst

@@ -195,11 +195,6 @@ the line with the key:
       value2
       value3
 
-.. describe:: packages (optional)
-
-   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.
-
 .. describe:: pypi_wheels (optional)
 
    A list of packages to download from PyPI, in the format ``name==version``.
@@ -208,6 +203,21 @@ the line with the key:
 
    .. versionadded:: 1.7
 
+.. describe:: extra_wheel_sources (optional)
+
+   One or more directory paths in which to find wheels, in addition to fetching
+   from PyPI. Each package will be retrieved from the first source containing a
+   compatible wheel, and all extra sources have priority over PyPI.
+
+   Relative paths are from the directory containing the config file.
+
+   .. versionadded:: 2.0
+
+.. describe:: packages (optional)
+
+   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.
+
 .. describe:: files (optional)
 
    Extra files or directories to be installed with your application.

+ 2 - 1
doc/index.rst

@@ -38,7 +38,8 @@ Quickstart
        files = LICENSE
            data_files/
 
-  See :doc:`cfgfile` for more details about this.
+   See :doc:`cfgfile` for more details about this, including how to bundle
+   packages which don't publish wheels.
 
 3. Run ``pynsist installer.cfg`` to generate your installer. If ``pynsist`` isn't
    found, you can use ``python -m nsist installer.cfg`` instead.

+ 2 - 0
doc/releasenotes.rst

@@ -10,6 +10,8 @@ above. For 'installer' format Python and older Python versions, use Pynsist 1.x
 
 * Pynsist installers can now install into a per-user directory, allowing them
   to be used without admin access.
+* Get wheels for the installer from local directories, by listing the
+  directories in ``extra_wheel_sources`` in the ``[Include]`` section.
 
 Version 1.12
 ------------

+ 8 - 3
nsist/__init__.py

@@ -76,6 +76,8 @@ class InstallerBuilder(object):
     :param dict commands: Dictionary keyed by command name, containing dicts
             defining the commands, as in the config file.
     :param list pypi_wheel_reqs: Package specifications to fetch from PyPI as wheels
+    :param extra_wheel_sources: Directory paths to find wheels in.
+    :type extra_wheel_sources: list of Path objects
     :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
@@ -88,12 +90,13 @@ class InstallerBuilder(object):
     :param str installer_name: Filename of the installer to produce
     :param str nsi_template: Path to a template NSI file to use
     """
-    def __init__(self, appname, version, shortcuts, publisher=None,
+    def __init__(self, appname, version, *, shortcuts, publisher=None,
                 icon=DEFAULT_ICON, packages=None, extra_files=None,
                 py_version=DEFAULT_PY_VERSION, py_bitness=DEFAULT_BITNESS,
                 py_format='bundled', inc_msvcrt=True, build_dir=DEFAULT_BUILD_DIR,
                 installer_name=None, nsi_template=None,
-                exclude=None, pypi_wheel_reqs=None, commands=None):
+                exclude=None, pypi_wheel_reqs=None, extra_wheel_sources=None,
+                commands=None):
         self.appname = appname
         self.version = version
         self.publisher = publisher
@@ -103,6 +106,7 @@ class InstallerBuilder(object):
         self.exclude = [os.path.normpath(p) for p in (exclude or [])]
         self.extra_files = extra_files or []
         self.pypi_wheel_reqs = pypi_wheel_reqs or []
+        self.extra_wheel_sources = extra_wheel_sources or []
         self.commands = commands or {}
 
         # Python options
@@ -319,7 +323,8 @@ if __name__ == '__main__':
 
         # 2. Wheels from PyPI
         fetch_pypi_wheels(self.pypi_wheel_reqs, build_pkg_dir,
-                          py_version=self.py_version, bitness=self.py_bitness)
+                          py_version=self.py_version, bitness=self.py_bitness,
+                          extra_sources=self.extra_wheel_sources)
 
         # 3. Copy importable modules
         copy_modules(self.packages, build_pkg_dir,

+ 5 - 0
nsist/configreader.py

@@ -2,6 +2,7 @@
 
 import configparser
 import os.path
+from pathlib import Path
 
 class SectionValidator(object):
     def __init__(self, keys):
@@ -71,6 +72,7 @@ CONFIG_VALIDATORS = {
     'Include': SectionValidator([
         ('packages', False),
         ('pypi_wheels', False),
+        ('extra_wheel_sources', False),
         ('files', False),
         ('exclude', False),
     ]),
@@ -220,6 +222,9 @@ def get_installer_builder_args(config):
     args['icon'] = appcfg.get('icon', DEFAULT_ICON)
     args['packages'] = config.get('Include', 'packages', fallback='').strip().splitlines()
     args['pypi_wheel_reqs'] = config.get('Include', 'pypi_wheels', fallback='').strip().splitlines()
+    args['extra_wheel_sources'] = [Path(p) for p in
+        config.get('Include', 'extra_wheel_sources', fallback='').strip().splitlines()
+    ]
     args['extra_files'] = read_extra_files(config)
     args['py_version'] = config.get('Python', 'version', fallback=DEFAULT_PY_VERSION)
     args['py_bitness'] = config.getint('Python', 'bitness', fallback=DEFAULT_BITNESS)

+ 47 - 9
nsist/pypi.py

@@ -27,11 +27,12 @@ def find_pypi_release(requirement):
 
 class NoWheelError(Exception): pass
 
-class WheelDownloader(object):
-    def __init__(self, requirement, py_version, bitness):
+class WheelLocator(object):
+    def __init__(self, requirement, py_version, bitness, extra_sources=None):
         self.requirement = requirement
         self.py_version = py_version
         self.bitness = bitness
+        self.extra_sources = extra_sources or []
 
         if requirement.count('==') != 1:
             raise ValueError("Requirement {!r} did not match name==version".format(requirement))
@@ -85,7 +86,30 @@ class WheelDownloader(object):
 
         return best
 
+    def check_extra_sources(self):
+        """Find a compatible wheel in the specified extra_sources directories.
+
+        Returns a Path or None.
+        """
+        whl_filename_prefix = '{name}-{version}-'.format(
+            name=re.sub("[^\w\d.]+", "_", self.name),
+            version=re.sub("[^\w\d.]+", "_", self.version),
+        )
+        for source in self.extra_sources:
+            candidates = [CachedRelease(p.name)
+                          for p in source.iterdir()
+                          if p.name.startswith(whl_filename_prefix)]
+            rel = self.pick_best_wheel(candidates)
+            if rel:
+                path = source / rel.filename
+                logger.info('Using wheel from extra directory: %s', path)
+                return path
+
     def check_cache(self):
+        """Find a wheel previously downloaded from PyPI in the cache.
+
+        Returns a Path or None.
+        """
         release_dir = get_cache_dir() / 'pypi' / self.name / self.version
         if not release_dir.is_dir():
             return None
@@ -98,11 +122,12 @@ class WheelDownloader(object):
         logger.info('Using cached wheel: %s', rel.filename)
         return release_dir / rel.filename
 
-    def fetch(self):
-        p = self.check_cache()
-        if p is not None:
-            return p
+    def get_from_pypi(self):
+        """Download a compatible wheel from PyPI.
 
+        Downloads to the cache directory and returns the destination as a Path.
+        Raises NoWheelError if no compatible wheel is found.
+        """
         release_list = yarg.get(self.name).release(self.version)
         preferred_release = self.pick_best_wheel(release_list)
         if preferred_release is None:
@@ -129,6 +154,18 @@ class WheelDownloader(object):
 
         return target
 
+    def fetch(self):
+        """Find and return a compatible wheel (main interface)"""
+        p = self.check_extra_sources()
+        if p is not None:
+            return p
+
+        p = self.check_cache()
+        if p is not None:
+            return p
+
+        return self.get_from_pypi()
+
 
 class CachedRelease(object):
     # Mock enough of the yarg Release object to be compatible with
@@ -204,8 +241,9 @@ def extract_wheel(whl_file, target_dir):
     shutil.rmtree(str(td))
 
 
-def fetch_pypi_wheels(requirements, target_dir, py_version, bitness):
+def fetch_pypi_wheels(requirements, target_dir, py_version, bitness,
+                      extra_sources=None):
     for req in requirements:
-        wd = WheelDownloader(req, py_version, bitness)
-        whl_file = wd.fetch()
+        wl = WheelLocator(req, py_version, bitness, extra_sources)
+        whl_file = wl.fetch()
         extract_wheel(whl_file, target_dir)