Selaa lähdekoodia

Start framework for installing commands

Thomas Kluyver 9 vuotta sitten
vanhempi
säilyke
e6a54b9f89
6 muutettua tiedostoa jossa 299 lisäystä ja 2 poistoa
  1. 16 2
      nsist/__init__.py
  2. 34 0
      nsist/_rewrite_shebangs.py
  3. 192 0
      nsist/_system_path.py
  4. 41 0
      nsist/commands.py
  5. 1 0
      nsist/nsiswriter.py
  6. 15 0
      nsist/pyapp.nsi

+ 16 - 2
nsist/__init__.py

@@ -6,6 +6,7 @@ import logging
 import ntpath
 import operator
 import os
+from pathlib import Path
 import re
 import shutil
 from subprocess import call
@@ -23,6 +24,7 @@ if os.name == 'nt':
 else:
     winreg = None
 
+from .commands import prepare_bin_directory
 from .copymodules import copy_modules
 from .nsiswriter import NSISFileWriter
 from .pypi import fetch_pypi_wheels
@@ -74,7 +76,8 @@ class InstallerBuilder(object):
             in the config file
     :param str icon: Path to an icon for the application
     :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 commands: List of dicts for commands to define.
+    :param list pypi_wheel_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 exclude: Paths of files to exclude that would otherwise be included
     :param str py_version: Full version of Python to bundle
@@ -88,7 +91,7 @@ class InstallerBuilder(object):
                 py_bitness=DEFAULT_BITNESS, py_format='installer',
                 build_dir=DEFAULT_BUILD_DIR,
                 installer_name=None, nsi_template=None,
-                exclude=None, pypi_wheel_reqs=None):
+                exclude=None, pypi_wheel_reqs=None, commands=None):
         self.appname = appname
         self.version = version
         self.shortcuts = shortcuts
@@ -97,6 +100,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.commands = commands or []
 
         # Python options
         self.py_version = py_version
@@ -340,6 +344,16 @@ if __name__ == '__main__':
         copy_modules(self.packages, build_pkg_dir,
                      py_version=self.py_version, exclude=self.exclude)
 
+    def prepare_commands(self):
+        command_dir = Path(self.build_dir) / 'bin'
+        if command_dir.is_dir():
+            shutil.rmtree(str(command_dir))
+        command_dir.mkdir()
+        prepare_bin_directory(command_dir, self.commands, bitness=self.py_bitness)
+        self.install_dirs.append((command_dir.name, '$INSTDIR'))
+        self.extra_files.append((pjoin(_PKGDIR, '_system_path.py'), '$INSTDIR'))
+        self.extra_files.append((pjoin(_PKGDIR, '_rewrite_shebangs.py'), '$INSTDIR'))
+
     def copytree_ignore_callback(self, directory, files):
         """This is being called back by our shutil.copytree call to implement the
         'exclude' feature.

+ 34 - 0
nsist/_rewrite_shebangs.py

@@ -0,0 +1,34 @@
+"""This is run during installation to rewrite the shebang (#! headers) of script
+files.
+"""
+import glob
+import os.path
+import sys
+
+if sys.version_info[0] >= 3:
+    # What do we do if the path contains characters outside the system code page?!
+    b_python_exe = sys.executable.encode(sys.getfilesystemencoding())
+else:
+    b_python_exe = sys.executable
+
+def rewrite(path):
+    with open(path, 'rb') as f:
+        contents = f.readlines()
+
+    if not contents:
+        return
+    if contents[0].strip() != b'#!python':
+        return
+
+    contents[0] = b'#!' + b_python_exe + b'\n'
+
+    with open(path, 'wb') as f:
+        f.writelines(contents)
+
+def main():
+    target_dir = sys.argv[1]
+    for path in glob.glob(os.path.join(target_dir, '*-script.py')):
+        rewrite(path)
+
+if __name__ == '__main__':
+    main()

+ 192 - 0
nsist/_system_path.py

@@ -0,0 +1,192 @@
+# (c) Continuum Analytics, Inc. / http://continuum.io
+# All Rights Reserved
+# Copied from conda constructor at commit d91adfb1c49666768ef9fd625d02276af6ddb0c9
+# This file is under the BSD license:
+#
+# Copyright (c) 2016, Continuum Analytics, Inc.
+# 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 Continuum Analytics, Inc. 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 CONTINUUM ANALYTICS 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.
+
+#
+# Helper script for adding and removing entries in the
+# Windows system path from the NSIS installer.
+
+__all__ = ['remove_from_system_path', 'add_to_system_path', 'broadcast_environment_settings_change']
+
+import sys
+import os, ctypes
+from os import path
+from ctypes import wintypes
+if sys.version_info[0] >= 3:
+    import winreg as reg
+else:
+    import _winreg as reg
+
+HWND_BROADCAST = 0xffff
+WM_SETTINGCHANGE = 0x001A
+SMTO_ABORTIFHUNG = 0x0002
+SendMessageTimeout = ctypes.windll.user32.SendMessageTimeoutW
+SendMessageTimeout.restype = None #wintypes.LRESULT
+SendMessageTimeout.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM,
+            wintypes.LPCWSTR, wintypes.UINT, wintypes.UINT, ctypes.POINTER(wintypes.DWORD)]
+
+def sz_expand(value, value_type):
+    if value_type == reg.REG_EXPAND_SZ:
+        return reg.ExpandEnvironmentStrings(value)
+    else:
+        return value
+
+def remove_from_system_path(pathname, allusers=True, path_env_var='PATH'):
+    """Removes all entries from the path which match the value in 'pathname'
+
+       You must call broadcast_environment_settings_change() after you are finished
+       manipulating the environment with this and other functions.
+
+       For example,
+         # Remove Anaconda from PATH
+         remove_from_system_path(r'C:\Anaconda')
+         broadcast_environment_settings_change()
+    """
+    pathname = path.normcase(path.normpath(pathname))
+
+    envkeys = [(reg.HKEY_CURRENT_USER, r'Environment')]
+    if allusers:
+        envkeys.append((reg.HKEY_LOCAL_MACHINE,
+            r'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'))
+    for root, keyname in envkeys:
+        key = reg.OpenKey(root, keyname, 0,
+                reg.KEY_QUERY_VALUE|reg.KEY_SET_VALUE)
+        reg_value = None
+        try:
+            reg_value = reg.QueryValueEx(key, path_env_var)
+        except WindowsError:
+            # This will happen if we're a non-admin install and the user has
+            # no PATH variable.
+            reg.CloseKey(key)
+            continue
+
+        try:
+            any_change = False
+            results = []
+            for v in reg_value[0].split(os.pathsep):
+                vexp = sz_expand(v, reg_value[1])
+                # Check if the expanded path matches the
+                # requested path in a normalized way
+                if path.normcase(path.normpath(vexp)) == pathname:
+                    any_change = True
+                else:
+                    # Append the original unexpanded version to the results
+                    results.append(v)
+
+            modified_path = os.pathsep.join(results)
+            if any_change:
+                reg.SetValueEx(key, path_env_var, 0, reg_value[1], modified_path)
+        except:
+            # If there's an error (e.g. when there is no PATH for the current
+            # user), continue on to try the next root/keyname pair
+            reg.CloseKey(key)
+
+def add_to_system_path(paths, allusers=True, path_env_var='PATH'):
+    """Adds the requested paths to the system PATH variable.
+
+       You must call broadcast_environment_settings_change() after you are finished
+       manipulating the environment with this and other functions.
+
+    """
+    # Make sure it's a list
+    if not issubclass(type(paths), list):
+        paths = [paths]
+
+    # Ensure all the paths are valid before we start messing with the
+    # registry.
+    new_paths = None
+    for p in paths:
+        p = path.abspath(p)
+        if not path.isdir(p):
+            raise RuntimeError(
+                'Directory "%s" does not exist, '
+                'cannot add it to the path' % p
+            )
+        if new_paths:
+            new_paths = new_paths + os.pathsep + p
+        else:
+            new_paths = p
+
+    if allusers:
+        # All Users
+        root, keyname = (reg.HKEY_LOCAL_MACHINE,
+            r'SYSTEM\CurrentControlSet\Control\Session Manager\Environment')
+    else:
+        # Just Me
+        root, keyname = (reg.HKEY_CURRENT_USER, r'Environment')
+
+    key = reg.OpenKey(root, keyname, 0,
+            reg.KEY_QUERY_VALUE|reg.KEY_SET_VALUE)
+
+    reg_type = None
+    reg_value = None
+    try:
+        try:
+            reg_value = reg.QueryValueEx(key, path_env_var)
+        except WindowsError:
+            # This will happen if we're a non-admin install and the user has
+            # no PATH variable; in which case, we can write our new paths
+            # directly.
+            reg_type = reg.REG_EXPAND_SZ
+            final_value = new_paths
+        else:
+            reg_type = reg_value[1]
+            # If we're an admin install, put us at the end of PATH.  If we're
+            # a user install, throw caution to the wind and put us at the
+            # start.  (This ensures we're picked up as the default python out
+            # of the box, regardless of whether or not the user has other
+            # pythons lying around on their PATH, which would complicate
+            # things.  It's also the same behavior used on *NIX.)
+            if allusers:
+                final_value = reg_value[0] + os.pathsep + new_paths
+            else:
+                final_value = new_paths + os.pathsep + reg_value[0]
+
+        reg.SetValueEx(key, path_env_var, 0, reg_type, final_value)
+
+    finally:
+        reg.CloseKey(key)
+
+def broadcast_environment_settings_change():
+    """Broadcasts to the system indicating that master environment variables have changed.
+
+    This must be called after using the other functions in this module to
+    manipulate environment variables.
+    """
+    SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, u'Environment',
+                SMTO_ABORTIFHUNG, 5000, ctypes.pointer(wintypes.DWORD()))
+
+
+def main():
+    if sys.argv[1] == 'add':
+        add_to_system_path(sys.argv[2])
+        broadcast_environment_settings_change()
+    elif sys.argv[1] == 'remove':
+        remove_from_system_path(sys.argv[2])
+        broadcast_environment_settings_change()

+ 41 - 0
nsist/commands.py

@@ -0,0 +1,41 @@
+import io
+import shutil
+import win_cli_launchers
+
+from .util import text_types
+
+SCRIPT_TEMPLATE = """#!python
+import sys, os
+installdir = os.path.dirname(os.path.dirname(__file__))
+pkgdir = os.path.join(installdir, 'pkgs')
+sys.path.insert(0, pkgdir)
+os.environ['PYTHONPATH'] = pkgdir + os.pathsep + os.environ.get('PYTHONPATH', '')
+
+{extra_preamble}
+
+if __name__ == '__main__':
+    from {module} import {func}
+    {func}()
+"""
+
+def prepare_bin_directory(target, commands, bitness=32):
+    exe_src = win_cli_launchers.find_exe('x64' if bitness == 64 else 'x86')
+    for command in commands:
+        name = command['name']
+        shutil.copy(exe_src, str(target / (name+'.exe')))
+
+        specified_preamble = command.get('extra_preamble', None)
+        if isinstance(specified_preamble, text_types):
+            # Filename
+            extra_preamble = io.open(specified_preamble, encoding='utf-8')
+        elif specified_preamble is None:
+            extra_preamble = io.StringIO()  # Empty
+        else:
+            # Passed a StringIO or similar object
+            extra_preamble = specified_preamble
+        module, func = command['entry_point'].split(':')
+        with (target / (name+'-script.py')).open('w') as f:
+            f.write(SCRIPT_TEMPLATE.format(
+                module=module, func=func,
+                extra_preamble=extra_preamble.read().rstrip(),
+            ))

+ 1 - 0
nsist/nsiswriter.py

@@ -55,6 +55,7 @@ class NSISFileWriter(object):
             'pjoin': ntpath.join,
             'single_shortcut': len(installerbuilder.shortcuts) == 1,
             'pynsist_pkg_dir': _PKGDIR,
+            'has_commands': len(installerbuilder.commands) > 0,
         }
 
         if installerbuilder.py_format == 'bundled':

+ 15 - 0
nsist/pyapp.nsi

@@ -80,6 +80,13 @@ Section "!${PRODUCT_NAME}" sec_app
   [% endif %]
   SetOutPath "$INSTDIR"
   [% endblock install_shortcuts %]
+
+  [% block install_commands %]
+  [% if has_commands %]
+    nsExec::ExecToLog '[[ python ]] -Es "$INSTDIR\_rewrite_shebangs.py" "$INSTDIR\bin"'
+    nsExec::ExecToLog '[[ python ]] -Es "$INSTDIR\_system_path.py" add "$INSTDIR\bin"'
+  [% endif %]
+  [% endblock install_commands %]
   
   ; Byte-compile Python files.
   DetailPrint "Byte-compiling Python modules..."
@@ -112,6 +119,14 @@ Section "Uninstall"
   Delete $INSTDIR\uninstall.exe
   Delete "$INSTDIR\${PRODUCT_ICON}"
   RMDir /r "$INSTDIR\pkgs"
+
+  ; Remove ourselves from %PATH%
+  [% block uninstall_commands %]
+  [% if has_commands %]
+    nsExec::ExecToLog '[[ python ]] -Es "$INSTDIR\_system_path" remove "$INSTDIR\bin"'
+  [% endif %]
+  [% endblock uninstall_commands %]
+
   ; Uninstall files
   [% for file, destination in ib.install_files %]
     Delete "[[pjoin(destination, file)]]"