Parcourir la source

Merge pull request #62 from takluyver/commands

Commands
Thomas Kluyver il y a 9 ans
Parent
commit
7aca03b778

+ 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 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} == '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 requests_download jinja2 yarg nose"
+  - "%PYTHON%\\python.exe -m pip install requests requests_download jinja2 yarg nose win_cli_launchers testpath"
 
 
 build: off
 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.
    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:
 .. _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
   `PyQt5 <https://github.com/takluyver/pynsist/tree/master/examples/pyqt>`__ and
   `Pyglet <https://github.com/takluyver/pynsist/tree/master/examples/pyglet>`__
   `Pyglet <https://github.com/takluyver/pynsist/tree/master/examples/pyglet>`__
   examples which use this feature.
   examples which use this feature.
+* Applications can include commands to run at the Windows command prompt. See
+  :ref:`command_config`.
 
 
 Version 1.6
 Version 1.6
 -----------
 -----------

+ 6 - 1
examples/console/installer.cfg

@@ -6,9 +6,14 @@ entry_point=guessnumber:main
 console=true
 console=true
 
 
 [Python]
 [Python]
-version=3.5.0rc1
+version=3.5.1
 bitness=64
 bitness=64
 format=bundled
 format=bundled
 
 
 [Include]
 [Include]
 packages=guessnumber
 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
     requests_download
     jinja2
     jinja2
     yarg
     yarg
+    win_cli_launchers
     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'
+dev-requires = testpath
 classifiers = License :: OSI Approved :: MIT License
 classifiers = License :: OSI Approved :: MIT License
     Intended Audience :: Developers
     Intended Audience :: Developers
     Environment :: Win32 (MS Windows)
     Environment :: Win32 (MS Windows)

+ 22 - 2
nsist/__init__.py

@@ -6,6 +6,7 @@ import logging
 import ntpath
 import ntpath
 import operator
 import operator
 import os
 import os
+from pathlib import Path
 import re
 import re
 import shutil
 import shutil
 from subprocess import call
 from subprocess import call
@@ -23,6 +24,7 @@ if os.name == 'nt':
 else:
 else:
     winreg = None
     winreg = None
 
 
+from .commands import prepare_bin_directory
 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 .pypi import fetch_pypi_wheels
@@ -74,7 +76,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 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 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 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
@@ -88,7 +92,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, pypi_wheel_reqs=None):
+                exclude=None, pypi_wheel_reqs=None, commands=None):
         self.appname = appname
         self.appname = appname
         self.version = version
         self.version = version
         self.shortcuts = shortcuts
         self.shortcuts = shortcuts
@@ -97,6 +101,7 @@ class InstallerBuilder(object):
         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 []
         self.pypi_wheel_reqs = pypi_wheel_reqs or []
+        self.commands = commands or {}
 
 
         # Python options
         # Python options
         self.py_version = py_version
         self.py_version = py_version
@@ -340,6 +345,16 @@ if __name__ == '__main__':
         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)
 
 
+    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):
     def copytree_ignore_callback(self, directory, files):
         """This is being called back by our shutil.copytree call to implement the
         """This is being called back by our shutil.copytree call to implement the
         'exclude' feature.
         'exclude' feature.
@@ -438,6 +453,9 @@ if __name__ == '__main__':
                 self.fetch_pylauncher()
                 self.fetch_pylauncher()
         
         
         self.prepare_shortcuts()
         self.prepare_shortcuts()
+
+        if self.commands:
+            self.prepare_commands()
         
         
         # Packages
         # Packages
         self.prepare_packages()
         self.prepare_packages()
@@ -478,6 +496,7 @@ def main(argv=None):
     try:
     try:
         cfg = configreader.read_and_validate(config_file)
         cfg = configreader.read_and_validate(config_file)
         shortcuts = configreader.read_shortcuts_config(cfg)
         shortcuts = configreader.read_shortcuts_config(cfg)
+        commands = configreader.read_commands_config(cfg)
     except configreader.InvalidConfig as e:
     except configreader.InvalidConfig as e:
         logger.error('Error parsing configuration file:')
         logger.error('Error parsing configuration file:')
         logger.error(str(e))
         logger.error(str(e))
@@ -490,6 +509,7 @@ def main(argv=None):
             version = appcfg['version'],
             version = appcfg['version'],
             icon = appcfg.get('icon', DEFAULT_ICON),
             icon = appcfg.get('icon', DEFAULT_ICON),
             shortcuts = shortcuts,
             shortcuts = shortcuts,
+            commands=commands,
             packages = cfg.get('Include', 'packages', fallback='').splitlines(),
             packages = cfg.get('Include', 'packages', fallback='').splitlines(),
             pypi_wheel_reqs = cfg.get('Include', 'pypi_wheels', 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),

+ 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),
         ('console', False),
         ('extra_preamble', False),
         ('extra_preamble', False),
     ]),
     ]),
+    'Command': SectionValidator([
+        ('entry_point', True),
+        ('extra_preamble', False),
+    ])
 }
 }
 
 
 class InvalidConfig(ValueError):
 class InvalidConfig(ValueError):
@@ -102,6 +106,8 @@ def read_and_validate(config_file):
             CONFIG_VALIDATORS[section].check(config, section)
             CONFIG_VALIDATORS[section].check(config, section)
         elif section.startswith('Shortcut '):
         elif section.startswith('Shortcut '):
             CONFIG_VALIDATORS['Shortcut'].check(config, section)
             CONFIG_VALIDATORS['Shortcut'].check(config, section)
+        elif section.startswith('Command '):
+            CONFIG_VALIDATORS['Command'].check(config, section)
         else:
         else:
             valid_section_names = CONFIG_VALIDATORS.keys()
             valid_section_names = CONFIG_VALIDATORS.keys()
             err_msg = ("{0} is not a valid section header. Must "
             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
     There is one shortcut per 'Shortcut <name>' section, and one for the
     Application section.
     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
     The optional 'icon' and 'console' fields will be filled with their
     default values if not supplied.
     default values if not supplied.
     """
     """
@@ -175,3 +181,21 @@ def read_shortcuts_config(cfg):
     _check_shortcut(appcfg['name'], appcfg, 'Application')
     _check_shortcut(appcfg['name'], appcfg, 'Application')
 
 
     return shortcuts
     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,
             'pjoin': ntpath.join,
             'single_shortcut': len(installerbuilder.shortcuts) == 1,
             'single_shortcut': len(installerbuilder.shortcuts) == 1,
             'pynsist_pkg_dir': _PKGDIR,
             'pynsist_pkg_dir': _PKGDIR,
+            'has_commands': len(installerbuilder.commands) > 0,
         }
         }
 
 
         if installerbuilder.py_format == 'bundled':
         if installerbuilder.py_format == 'bundled':

+ 15 - 0
nsist/pyapp.nsi

@@ -80,6 +80,13 @@ Section "!${PRODUCT_NAME}" sec_app
   [% endif %]
   [% endif %]
   SetOutPath "$INSTDIR"
   SetOutPath "$INSTDIR"
   [% endblock install_shortcuts %]
   [% 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.
   ; Byte-compile Python files.
   DetailPrint "Byte-compiling Python modules..."
   DetailPrint "Byte-compiling Python modules..."
@@ -112,6 +119,14 @@ Section "Uninstall"
   Delete $INSTDIR\uninstall.exe
   Delete $INSTDIR\uninstall.exe
   Delete "$INSTDIR\${PRODUCT_ICON}"
   Delete "$INSTDIR\${PRODUCT_ICON}"
   RMDir /r "$INSTDIR\pkgs"
   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
   ; Uninstall files
   [% for file, destination in ib.install_files %]
   [% for file, destination in ib.install_files %]
     Delete "[[pjoin(destination, file)]]"
     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('#!"')