浏览代码

Merge pull request #62 from takluyver/commands

Commands
Thomas Kluyver 9 年之前
父节点
当前提交
7aca03b778

+ 1 - 1
.travis.yml

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

+ 1 - 1
appveyor.yml

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

+ 28 - 1
doc/cfgfile.rst

@@ -99,7 +99,34 @@ shortcuts by defining sections titled :samp:`Shortcut {Name}`. For example:
 
    These options all work the same way as in the Application section.
 
-.. versionadded:: 0.2
+
+.. _command_config:
+
+Command sections
+----------------
+
+.. versionadded:: 1.7
+
+Your application can install commands to be run from the Windows command prompt.
+This is not standard practice for desktop applications on Windows, but if your
+application specifically provides a command line interface, you can define
+one or more sections titled :samp:`Command {name}`::
+
+    [Command guessnumber]
+    entry_point=guessnumber:main
+
+If you use this, the installer will modify the system :envvar:`PATH` environment
+variable.
+
+.. describe:: entry_point
+
+   As with shortcuts, this specifies the Python function to call, in the format
+   ``module:function``.
+
+.. describe:: extra_preamble (optional)
+
+   As for shortcuts, a file containing extra code to run before importing the
+   module from ``entry_point``. This should rarely be needed.
 
 .. _cfg_python:
 

+ 2 - 0
doc/releasenotes.rst

@@ -8,6 +8,8 @@ Version 1.7
   `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.
+* Applications can include commands to run at the Windows command prompt. See
+  :ref:`command_config`.
 
 Version 1.6
 -----------

+ 6 - 1
examples/console/installer.cfg

@@ -6,9 +6,14 @@ entry_point=guessnumber:main
 console=true
 
 [Python]
-version=3.5.0rc1
+version=3.5.1
 bitness=64
 format=bundled
 
 [Include]
 packages=guessnumber
+
+# This optional section adds a command which can be run from the Windows
+# command prompt.
+[Command guessnumber]
+entry_point=guessnumber:main

+ 2 - 0
flit.ini

@@ -9,8 +9,10 @@ requires = requests
     requests_download
     jinja2
     yarg
+    win_cli_launchers
     configparser; python_version == '2.7'
     pathlib; python_version == '2.7' or python_version == '3.3'
+dev-requires = testpath
 classifiers = License :: OSI Approved :: MIT License
     Intended Audience :: Developers
     Environment :: Win32 (MS Windows)

+ 22 - 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,9 @@ 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 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 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 +92,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 +101,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 +345,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.
@@ -438,6 +453,9 @@ if __name__ == '__main__':
                 self.fetch_pylauncher()
         
         self.prepare_shortcuts()
+
+        if self.commands:
+            self.prepare_commands()
         
         # Packages
         self.prepare_packages()
@@ -478,6 +496,7 @@ def main(argv=None):
     try:
         cfg = configreader.read_and_validate(config_file)
         shortcuts = configreader.read_shortcuts_config(cfg)
+        commands = configreader.read_commands_config(cfg)
     except configreader.InvalidConfig as e:
         logger.error('Error parsing configuration file:')
         logger.error(str(e))
@@ -490,6 +509,7 @@ def main(argv=None):
             version = appcfg['version'],
             icon = appcfg.get('icon', DEFAULT_ICON),
             shortcuts = shortcuts,
+            commands=commands,
             packages = cfg.get('Include', 'packages', fallback='').splitlines(),
             pypi_wheel_reqs = cfg.get('Include', 'pypi_wheels', fallback='').splitlines(),
             extra_files = configreader.read_extra_files(cfg),

+ 36 - 0
nsist/_rewrite_shebangs.py

@@ -0,0 +1,36 @@
+"""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(argv=None):
+    if argv is None:
+        argv = sys.argv
+    target_dir = argv[1]
+    for path in glob.glob(os.path.join(target_dir, '*-script.py')):
+        rewrite(path)
+
+if __name__ == '__main__':
+    main()

+ 195 - 0
nsist/_system_path.py

@@ -0,0 +1,195 @@
+# (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()
+
+if __name__ == '__main__':
+    main()

+ 40 - 0
nsist/commands.py

@@ -0,0 +1,40 @@
+import io
+import shutil
+import win_cli_launchers
+
+from .util import text_types
+
+SCRIPT_TEMPLATE = u"""#!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 name, command in commands.items():
+        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(),
+            ))

+ 25 - 1
nsist/configreader.py

@@ -87,6 +87,10 @@ CONFIG_VALIDATORS = {
         ('console', False),
         ('extra_preamble', False),
     ]),
+    'Command': SectionValidator([
+        ('entry_point', True),
+        ('extra_preamble', False),
+    ])
 }
 
 class InvalidConfig(ValueError):
@@ -102,6 +106,8 @@ def read_and_validate(config_file):
             CONFIG_VALIDATORS[section].check(config, section)
         elif section.startswith('Shortcut '):
             CONFIG_VALIDATORS['Shortcut'].check(config, section)
+        elif section.startswith('Command '):
+            CONFIG_VALIDATORS['Command'].check(config, section)
         else:
             valid_section_names = CONFIG_VALIDATORS.keys()
             err_msg = ("{0} is not a valid section header. Must "
@@ -134,7 +140,7 @@ def read_shortcuts_config(cfg):
     There is one shortcut per 'Shortcut <name>' section, and one for the
     Application section.
     
-    Returns a list of dictionaries with the fields from the shortcut sections.
+    Returns a dict of dicts with the fields from the shortcut sections.
     The optional 'icon' and 'console' fields will be filled with their
     default values if not supplied.
     """
@@ -175,3 +181,21 @@ def read_shortcuts_config(cfg):
     _check_shortcut(appcfg['name'], appcfg, 'Application')
 
     return shortcuts
+
+def read_commands_config(cfg):
+    """Read and verify the command definitions from the config file.
+
+    Returns a dict of dicts, keyed by command name, containing the values from
+    the command sections of the config file.
+    """
+    commands = {}
+    for section in cfg.sections():
+        if section.startswith("Command "):
+            name = section[len("Command "):]
+            commands[name] = cc = dict(cfg[section])
+            if ('extra_preamble' in cc) and \
+                    not os.path.isfile(cc['extra_preamble']):
+                raise InvalidConfig('extra_preamble file %r does not exist' %
+                                    cc['extra_preamble'])
+
+    return commands

+ 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.py" remove "$INSTDIR\bin"'
+  [% endif %]
+  [% endblock uninstall_commands %]
+
   ; Uninstall files
   [% for file, destination in ib.install_files %]
     Delete "[[pjoin(destination, file)]]"

+ 30 - 0
nsist/tests/test_commands.py

@@ -0,0 +1,30 @@
+import io
+from nose.tools import *
+from pathlib import Path
+from testpath.tempdir import TemporaryDirectory
+from testpath import assert_isfile
+
+from nsist import commands, _rewrite_shebangs
+
+cmds = {'acommand': {
+                'entry_point': 'somemod:somefunc',
+                'extra_preamble': io.StringIO(u'import extra')
+           }}
+
+def test_prepare_bin_dir():
+    with TemporaryDirectory() as td:
+        td = Path(td)
+        commands.prepare_bin_directory(td, cmds)
+        assert_isfile(td / 'acommand.exe')
+        script_file = td / 'acommand-script.py'
+        assert_isfile(script_file)
+
+        with script_file.open() as f:
+            script_contents = f.read()
+        assert script_contents.startswith("#!python")
+        assert_in('import extra', script_contents)
+        assert_in('somefunc()', script_contents)
+
+        _rewrite_shebangs.main(['_rewrite_shebangs.py', str(td)])
+        with script_file.open() as f:
+            assert f.read().startswith('#!"')