Procházet zdrojové kódy

Merge branch 'master' of github.com:takluyver/pynsist

Conflicts:
	nsist/nsigen.py
Thomas Kluyver před 10 roky
rodič
revize
2e5b31db0a
9 změnil soubory, kde provedl 247 přidání a 344 odebrání
  1. 35 0
      doc/cfgfile.rst
  2. 37 9
      nsist/__init__.py
  3. 1 0
      nsist/configreader.py
  4. 53 11
      nsist/copymodules.py
  5. 0 209
      nsist/nsigen.py
  6. 30 99
      nsist/nsiswriter.py
  7. 70 15
      nsist/pyapp.nsi
  8. 20 0
      nsist/pyapp_w_pylauncher.nsi
  9. 1 1
      setup.py

+ 35 - 0
doc/cfgfile.rst

@@ -157,6 +157,38 @@ the line with the key:
        [Include]
        files=mydata.dat > $COMMONFILES
 
+.. describe:: exclude (optional)
+
+   Files to be excluded from your installer. This can be used to include a
+   Python library or extra directory only partially, for example to include
+   large monolithic python packages without their samples and test suites to
+   achieve a smaller installer file.
+
+   Please note:
+
+   * The parameter is expected to contain a list of files *relative to the
+     build directory*. Therefore, to include files from a package, you have to
+     start your pattern with ``pkgs/<packagename>/``.
+   * You can use `wildcard characters`_ like ``*`` or ``?``, similar to a Unix 
+     shell.
+   * If you want to exclude whole subfolders, do *not* put a path separator 
+     (e.g. ``/``) at their end.
+   * The exclude patterns are only applied to packages and to directories
+     specified using the ``files`` option. If your ``exclude`` option directly 
+     contradicts your ``files`` or ``packages`` option, the files in question
+     will be included (you can not exclude a full package/extra directory
+     or a single file listed in ``files``).
+
+   Example:
+
+   .. code-block:: ini
+
+       [Include]
+       packages=PySide
+       files=data_dir
+       exclude=pkgs/PySide/examples
+         data_dir/ignoredfile
+
 Build section
 -------------
 
@@ -179,3 +211,6 @@ Build section
    of extra files and folders to be installed. See the
    `NSIS Scripting Reference <http://nsis.sourceforge.net/Docs/Chapter4.html>`_
    for details of the format.
+
+
+.. _wildcard characters: https://docs.python.org/3/library/fnmatch.html

+ 37 - 9
nsist/__init__.py

@@ -10,6 +10,7 @@ import re
 import shutil
 from subprocess import call
 import sys
+import fnmatch
 
 PY2 = sys.version_info[0] == 2
 
@@ -31,9 +32,9 @@ pjoin = os.path.join
 logger = logging.getLogger(__name__)
 
 _PKGDIR = os.path.abspath(os.path.dirname(__file__))
-DEFAULT_PY_VERSION = '2.7.8' if PY2 else '3.4.1'
+DEFAULT_PY_VERSION = '2.7.9' if PY2 else '3.4.2'
 DEFAULT_BUILD_DIR = pjoin('build', 'nsis')
-DEFAULT_NSI_TEMPLATE = pjoin(_PKGDIR, 'template.nsi')
+DEFAULT_NSI_TEMPLATE = 'pyapp_w_launcher.nsi' if PY2 else 'pyapp.nsi'
 DEFAULT_ICON = pjoin(_PKGDIR, 'glossyorb.ico')
 if os.name == 'nt' and sys.maxsize == (2**63)-1:
     DEFAULT_BITNESS = 64
@@ -78,15 +79,17 @@ 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, icon=DEFAULT_ICON, 
+    def __init__(self, appname, version, shortcuts, icon=DEFAULT_ICON,
                 packages=None, extra_files=None, py_version=DEFAULT_PY_VERSION,
                 py_bitness=DEFAULT_BITNESS, build_dir=DEFAULT_BUILD_DIR,
-                installer_name=None, nsi_template=DEFAULT_NSI_TEMPLATE):
+                installer_name=None, nsi_template=DEFAULT_NSI_TEMPLATE,
+                exclude=None):
         self.appname = appname
         self.version = version
         self.shortcuts = shortcuts
         self.icon = icon
         self.packages = packages or []
+        self.exclude = exclude or []
         self.extra_files = extra_files or []
         self.py_version = py_version
         if not self._py_version_pattern.match(py_version):
@@ -242,7 +245,25 @@ if __name__ == '__main__':
             shutil.copytree('pynsist_pkgs', build_pkg_dir)
         else:
             os.mkdir(build_pkg_dir)
-        copy_modules(self.packages, build_pkg_dir, py_version=self.py_version)
+        copy_modules(self.packages, build_pkg_dir,
+                     py_version=self.py_version, exclude=self.exclude)
+
+    def copytree_ignore_callback(self, directory, files):
+        """This is being called back by our shutil.copytree call to implement the
+        'exclude' feature.
+        """
+        ignored = set()
+
+        # Filter by file names relative to the build directory
+        files = [os.path.join(directory, fname) for fname in files]
+
+        # Execute all patterns
+        for pattern in self.exclude:
+            ignored.update([
+                os.path.basename(fname)
+                for fname in fnmatch.filter(files, pattern)
+            ])
+        return ignored
 
     def copy_extra_files(self):
         """Copy a list of files into the build directory, and add them to
@@ -254,22 +275,28 @@ if __name__ == '__main__':
 
             if not destination:
                 destination = '$INSTDIR'
-    
+
             if os.path.isdir(file):
                 target_name = pjoin(self.build_dir, basename)
                 if os.path.isdir(target_name):
                     shutil.rmtree(target_name)
                 elif os.path.exists(target_name):
                     os.unlink(target_name)
-                shutil.copytree(file, target_name)
+                if self.exclude:
+                    shutil.copytree(file, target_name,
+                                    ignore=self.copytree_ignore_callback)
+                else:
+                    # Don't use our exclude callback if we don't need to,
+                    # as it slows things down.
+                    shutil.copytree(file, target_name)
                 self.install_dirs.append((basename, destination))
             else:
                 shutil.copy2(file, self.build_dir)
                 self.install_files.append((basename, destination))
-    
+
     def write_nsi(self):
         """Write the NSI file to define the NSIS installer.
-        
+
         Most of the details of this are in the template and the
         :class:`nsist.nsiswriter.NSISFileWriter` class.
         """
@@ -377,6 +404,7 @@ def main(argv=None):
             build_dir = cfg.get('Build', 'directory', fallback=DEFAULT_BUILD_DIR),
             installer_name = cfg.get('Build', 'installer_name', fallback=None),
             nsi_template = cfg.get('Build', 'nsi_template', fallback=DEFAULT_NSI_TEMPLATE),
+            exclude = cfg.get('Include', 'exclude', fallback='').splitlines(),
         ).run()
     except InputError as e:
         logger.error("Error in config values:")

+ 1 - 0
nsist/configreader.py

@@ -70,6 +70,7 @@ CONFIG_VALIDATORS = {
     'Include': SectionValidator([
         ('packages', False),
         ('files', False),
+        ('exclude', False),
     ]),
     'Python': SectionValidator([
         ('version', True),

+ 53 - 11
nsist/copymodules.py

@@ -3,6 +3,8 @@ import shutil
 import sys
 import tempfile
 import zipfile, zipimport
+import fnmatch
+from functools import partial
 
 pjoin = os.path.join
 
@@ -63,6 +65,26 @@ def copy_zipmodule(loader, modname, target):
 
     shutil.rmtree(tempdir)
 
+def copytree_ignore_callback(excludes, pkgdir, modname, directory, files):
+    """This is being called back by our shutil.copytree call to implement the
+    'exclude' feature.
+    """
+    ignored = set()
+
+    # Filter by file names relative to the build directory
+    reldir = os.path.relpath(directory, pkgdir)
+    target = os.path.join('pkgs/', modname, reldir)
+    files = [os.path.join(target, fname) for fname in files]
+
+    # Execute all patterns
+    for pattern in excludes + ['*.pyc']:
+        ignored.update([
+            os.path.basename(fname)
+            for fname in fnmatch.filter(files, pattern)
+        ])
+
+    return ignored
+
 if not PY2:
     import importlib
     import importlib.abc
@@ -78,7 +100,7 @@ if not PY2:
             self.py_version = py_version
             self.path = path if (path is not None) else ([''] + sys.path)
 
-        def copy(self, modname, target):
+        def copy(self, modname, target, exclude):
             """Copy the importable module 'modname' to the directory 'target'.
 
             modname should be a top-level import, i.e. without any dots.
@@ -104,8 +126,18 @@ if not PY2:
                     assert basename.startswith('__init__')
                     check_package_for_ext_mods(pkgdir, self.py_version)
                     dest = os.path.join(target, modname)
-                    shutil.copytree(pkgdir, dest,
-                                    ignore=shutil.ignore_patterns('*.pyc'))
+                    if exclude:
+                        shutil.copytree(
+                            pkgdir, dest,
+                            ignore=partial(copytree_ignore_callback, exclude, pkgdir, modname)
+                        )
+                    else:
+                        # Don't use our exclude callback if we don't need to,
+                        # as it slows things down.
+                        shutil.copytree(
+                            pkgdir, dest,
+                            ignore=shutil.ignore_patterns('*.pyc')
+                        )
                 else:
                     shutil.copy2(file, target)
 
@@ -125,7 +157,7 @@ else:
             self.path = path if (path is not None) else ([''] + sys.path)
             self.zip_paths = [p for p in self.path if zipfile.is_zipfile(p)]
 
-        def copy(self, modname, target):
+        def copy(self, modname, target, exclude):
             """Copy the importable module 'modname' to the directory 'target'.
 
             modname should be a top-level import, i.e. without any dots.
@@ -162,25 +194,35 @@ else:
             elif modtype == imp.PKG_DIRECTORY:
                 check_package_for_ext_mods(path, self.py_version)
                 dest = os.path.join(target, modname)
-                shutil.copytree(path, dest,
-                                ignore=shutil.ignore_patterns('*.pyc'))
+                if exclude:
+                    shutil.copytree(
+                        path, dest,
+                        ignore=partial(copytree_ignore_callback, exclude, path, modname)
+                    )
+                else:
+                    # Don't use our exclude callback if we don't need to,
+                    # as it slows things down.
+                    shutil.copytree(
+                        path, dest,
+                        ignore=shutil.ignore_patterns('*.pyc')
+                    )
 
 
-def copy_modules(modnames, target, py_version, path=None):
+def copy_modules(modnames, target, py_version, path=None, exclude=None):
     """Copy the specified importable modules to the target directory.
-    
+
     By default, it finds modules in :data:`sys.path` - this can be overridden
     by passing the path parameter.
     """
     mc = ModuleCopier(py_version, path)
     files_in_target_noext = [os.path.splitext(f)[0] for f in os.listdir(target)]
-    
+
     for modname in modnames:
         if modname in files_in_target_noext:
             # Already there, no need to copy it.
             continue
-        mc.copy(modname, target)
-    
+        mc.copy(modname, target, exclude)
+
     if not modnames:
         # NSIS abhors an empty folder, so give it a file to find.
         with open(os.path.join(target, 'placeholder'), 'w') as f:

+ 0 - 209
nsist/nsigen.py

@@ -1,209 +0,0 @@
-import re
-
-class Comment(object):
-    def __init__(self, content):
-        self.content = content
-
-    def generate(self, state=None):
-        yield "; " + self.content
-
-_escaped_chars_re = re.compile(r'[\r\n\t"]')
-def _quote(arg):
-    if arg == '':
-        return '""'
-
-    def repl(match):
-        char = match.group(0)
-        if char == '"':
-            return '$\\"'
-        else:
-            return '$' + repr(char)[1:-1]
-    arg, nsub = _escaped_chars_re.subn(repl, arg)
-    
-    if nsub > 0 or (' ' in arg) or ('${' in arg) or ('#' in arg):
-        return '"%s"' % arg
-        
-    return arg
-
-def assemble_line(parts):
-    quoted_parts = [_quote(p) for p in parts if p is not None]
-    return ' '.join(quoted_parts)
-
-class Instruction(object):
-    def __init__(self, name, *parameters):
-        self.name = name
-        self.parameters = parameters
-
-    def generate(self, state=None):
-        yield assemble_line([self.name] + list(self.parameters))
-
-def make_instruction_class(name):
-    def __init__(self, *parameters):
-        Instruction.__init__(self, name, *parameters)
-
-    return type(name, (Instruction,), {'__init__': __init__})
-
-File = make_instruction_class('File')
-DetailPrint = make_instruction_class('DetailPrint')
-
-class Scope(object):
-    def __init__(self, contents=None):
-        self.contents = contents or []
-
-    def generate_children(self, state=None):
-        for child in self.contents:
-            if isinstance(child, str):
-                yield child
-            else:
-                for line in child.generate(state):
-                    yield '  ' + line
-
-class Section(Scope):
-    def __init__(self, name=None, index_var=None, selected=True, contents=None):
-        super(Section, self).__init__(contents)
-        self.name = name
-        self.index_var = index_var
-        self.selected = selected
-
-    def generate(self, state=None):
-        parts = ['Section',
-                 None if self.selected else '/o',
-                 self.name, self.index_var]
-
-        yield assemble_line(parts)
-        for line in self.generate_children(state):
-            yield line
-        yield 'SectionEnd'
-        yield ''  # Blank line after a section
-
-class SectionGroup(Scope):
-    def __init__(self, name, index_var=None, expanded=False, contents=None):
-        super(SectionGroup, self).__init__(contents)
-        self.name = name
-        self.expanded = expanded
-        self.index_var = index_var
-
-    def generate(self, state=None):
-        parts = ['SectionGroup',
-                 '/e' if self.expanded else None,
-                 self.name, self.index_var]
-        yield assemble_line(parts)
-        for line in self.generate_children(state):
-            yield line
-        yield 'SectionGroupEnd'
-
-class Function(Scope):
-    def __init__(self, name, contents=None):
-        super(Function, self).__init__(contents)
-        self.name = name
-
-    def generate(self, state=None):
-        yield assemble_line(['Function', self.name])
-        for line in self.generate_children(state):
-            yield line
-        yield 'FunctionEnd'
-        yield ''  # Blank line after a function
-
-class Label(object):
-    def __init__(self, name):
-        self.name = name
-
-    def generate(self, scope):
-        yield self.name + ":"
-
-class Conditional(object):
-    # e.g. IfSilent, IntCmp
-    def __init__(self, name, *parameters, ifbody=None, elsebody=None):
-        self.name = name
-        self.parameters = parameters
-        self.ifbody = ifbody or []
-        self.elsebody = elsebody or []
-
-    def generate(self, state):
-        if_counter = state.get('if_counter', 1)
-        state['if_counter'] = if_counter + 1
-        yield assemble_line([self.name] + list(self.parameters) + ["0",
-                     ("else%d" if self.elsebody else "endif%d") % if_counter])
-        for child in self.ifbody:
-            for line in child.generate(state):
-                yield "  " + line
-
-        if self.elsebody:
-            yield ("  Goto endif%d" % if_counter)
-            yield ("else%d:" % if_counter)
-            for child in self.elsebody:
-                for line in child.generate(state):
-                    yield "  " + line
-
-        yield ("endif%d:" % if_counter)
-
-
-class CompilerCommand(object):
-    def __init__(self, name, *parameters):
-        self.name = name
-        self.parameters = parameters
-
-    def generate(self, state=None):
-        yield "!" + assemble_line([self.name] + list(self.parameters))
-
-class CompilerConditional(object):
-    # ifdef etc
-    def __init__(self, name, parameters, ifbody=None, elsebody=None):
-        self.name = name
-        self.parameters = parameters
-        self.ifbody = ifbody or []
-        self.elsebody = elsebody or []
-
-    def generate(self, state=None):
-        yield "!" + assemble_line([self.name] + self.parameters)
-        for child in self.ifbody:
-            for line in child.generate(state):
-                yield '  ' + line
-        if self.elsebody:
-            yield "!else"
-            for child in self.elsebody:
-                for line in child.generate(state):
-                    yield '  ' + line
-
-        yield "!endif"
-
-def make_compiler_command_class(name):
-    def __init__(self, *parameters):
-        CompilerCommand.__init__(self, name, *parameters)
-
-    return type(name, (CompilerCommand,), {'__init__': __init__})
-
-define = make_compiler_command_class('define')
-insertmacro = make_compiler_command_class('insertmacro')
-
-class Macro(Scope):
-    def __init__(self, name, parameters):
-        super(Macro, self).__init__()
-        self.name = name
-        self.parameters = parameters
-
-    def generate(self, state=None):
-        yield "!" + assemble_line([self.name] + self.parameters)
-        for line in self.generate_children(state):
-            yield "  " + line
-        yield "!macroend"
-
-class Document(object):
-    def __init__(self, children=None):
-        self.children = children or []
-
-    def generate(self, state):
-        for child in self.children:
-            if isinstance(child, str):
-                yield child
-            else:
-                for line in child.generate(state):
-                    yield line
-
-    def write(self, fileobj_or_path):
-        if isinstance(fileobj_or_path, str):
-            with open(fileobj_or_path, 'w') as f:
-                return self.write(f)
-
-        for line in self.generate(state={}):
-            fileobj_or_path.write(line + '\n')

+ 30 - 99
nsist/nsiswriter.py

@@ -1,9 +1,14 @@
 import itertools
 import operator
+import os
 import ntpath
 import re
 import sys
 
+import jinja2
+
+_PKGDIR = os.path.abspath(os.path.dirname(__file__))
+
 PY2 = sys.version_info[0] == 2
 
 
@@ -16,111 +21,37 @@ class NSISFileWriter(object):
         :param str template_file: Path to the .nsi template
         :param dict definitions: Mapping of name to value (values will be quoted)
         """
-        self.template_file = template_file
+        env = jinja2.Environment(loader=jinja2.FileSystemLoader([
+            _PKGDIR,
+            os.getcwd()
+        ]),
+        # Change template markers from {}, which NSIS uses, to [], which it
+        # doesn't much, so it's easier to distinguishing our templating from
+        # NSIS preprocessor variables.
+        block_start_string="[%",
+        block_end_string="%]",
+        variable_start_string="[[",
+        variable_end_string="]]",
+        comment_start_string="[#",
+        comment_end_string="#]",
+        )
+        self.template = env.get_template(template_file)
         self.installerbuilder = installerbuilder
-        self.definitions = definitions or {}
-        self.template_fields = {
-                ';INSTALL_FILES': self.files_install,
-                ';INSTALL_DIRECTORIES': self.dirs_install,
-                ';INSTALL_SHORTCUTS': self.shortcuts_install,
-                ';UNINSTALL_FILES': self.files_uninstall,
-                ';UNINSTALL_DIRECTORIES': self.dirs_uninstall,
-                ';UNINSTALL_SHORTCUTS': self.shortcuts_uninstall,
+        self.namespace = ns = {
+            'ib': installerbuilder,
+            'grouped_files': itertools.groupby(
+                   self.installerbuilder.install_files, operator.itemgetter(1)),
+            'icon': os.path.basename(installerbuilder.icon),
+            'arch_tag': '.amd64' if (installerbuilder.py_bitness==64) else '',
+            'pjoin': ntpath.join,
+            'single_shortcut': len(installerbuilder.shortcuts) == 1,
         }
-        if installerbuilder.py_version < '3.3':
-            self.template_fields.update({
-                ';PYLAUNCHER_INSTALL': self.pylauncher_install,
-                ';PYLAUNCHER_HELP': self.pylauncher_help})
 
     def write(self, target):
         """Fill out the template and write the result to 'target'.
         
         :param str target: Path to the file to be written
         """
-        with open(target, 'w') as fout, open(self.template_file) as fin:
-            self.write_definitions(fout)
-
-            for line in fin:
-                fout.write(line)
-                l = line.strip()
-                if l in self.template_fields:
-                    indent = re.match('\s*', line).group(0)
-                    for fillline in self.template_fields[l]():
-                        fout.write(indent+fillline+'\n')
-
-    def write_definitions(self, f):
-        """Write definition lines at the start of the file.
-        
-        :param f: A text-mode writable file handle
-        """
-        for name, value in self.definitions.items():
-            f.write('!define {} "{}"\n'.format(name, value))
-
-    # Template fillers
-    # ----------------
-
-    # These return an iterable of lines to fill after a given template field
-
-    def files_install(self):
-        for destination, group in itertools.groupby(
-                    self.installerbuilder.install_files, operator.itemgetter(1)):
-            yield 'SetOutPath "{}"'.format(destination)
-            for file, _ in group:
-                yield 'File "{}"'.format(file)
-        yield 'SetOutPath "$INSTDIR"'
-
-    def dirs_install(self):
-        for dir, destination in self.installerbuilder.install_dirs:
-            yield 'SetOutPath "{}"'.format(ntpath.join(destination, dir))
-            yield 'File /r "{}\*.*"'.format(dir)
-        yield 'SetOutPath "$INSTDIR"'
-    
-    def shortcuts_install(self):
-        shortcuts = self.installerbuilder.shortcuts
-        # The output path becomes the working directory for shortcuts.
-        yield 'SetOutPath "%HOMEDRIVE%\\%HOMEPATH%"'
-        if len(shortcuts) == 1:
-            scname, sc = next(iter(shortcuts.items()))
-            yield 'CreateShortCut "$SMPROGRAMS\{}.lnk" "{}" \'"$INSTDIR\{}"\' \\'.format(\
-                    scname, ('py' if sc['console'] else 'pyw'), sc['script'])
-            yield '    "$INSTDIR\{}"'.format(sc['icon'])
-            yield 'SetOutPath "$INSTDIR"'
-            return
-        
-        # Multiple shortcuts - make a folder
-        yield 'CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"'
-        for scname, sc in shortcuts.items():
-            yield 'CreateShortCut "$SMPROGRAMS\${{PRODUCT_NAME}}\{}.lnk" "{}" \\'.format(\
-                    scname, sc['target'])
-            yield '    \'{}\' "$INSTDIR\{}"'.format(sc['parameters'], sc['icon'])
-        yield 'SetOutPath "$INSTDIR"'
-
-    def files_uninstall(self):
-        for file, destination in self.installerbuilder.install_files:
-            yield 'Delete "{}"'.format(ntpath.join(destination, file))
-
-    def dirs_uninstall(self):
-        for dir, destination in self.installerbuilder.install_dirs:
-            yield 'RMDir /r "{}"'.format(ntpath.join(destination, dir))
-    
-    def shortcuts_uninstall(self):
-        shortcuts = self.installerbuilder.shortcuts
-        if len(shortcuts) == 1:
-            scname = next(iter(shortcuts))
-            yield 'Delete "$SMPROGRAMS\{}.lnk"'.format(scname)
-        else:
-            yield 'RMDir /r "$SMPROGRAMS\${PRODUCT_NAME}"'
-
-    def pylauncher_install(self):
-        return ["Section \"PyLauncher\" sec_pylauncher",
-            "    File \"launchwin${ARCH_TAG}.msi\"",
-            "    ExecWait 'msiexec /i \"$INSTDIR\launchwin${ARCH_TAG}.msi\" /qb ALLUSERS=1'",
-            "    Delete $INSTDIR\launchwin${ARCH_TAG}.msi",
-            "SectionEnd",
-           ]
+        with open(target, 'w') as fout:
+            fout.write(self.template.render(self.namespace))
 
-    def pylauncher_help(self):
-        return ["StrCmp $0 ${sec_pylauncher} 0 +2",
-                "SendMessage $R0 ${WM_SETTEXT} 0 \"STR:The Python launcher. \\",
-                "    This is required for ${PRODUCT_NAME} to run.\"",
-               ]

+ 70 - 15
nsist/template.nsi → nsist/pyapp.nsi

@@ -1,10 +1,18 @@
-
-; Definitions will be added above
+!define PRODUCT_NAME "[[ib.appname]]"
+!define PRODUCT_VERSION "[[ib.version]]"
+!define PY_VERSION "[[ib.py_version]]"
+!define PY_MAJOR_VERSION "[[ib.py_major_version]]"
+!define PY_QUALIFIER "[[ib.py_qualifier]]"
+!define BITNESS "[[ib.py_bitness]]"
+!define ARCH_TAG "[[arch_tag]]"
+!define INSTALLER_NAME "[[ib.installer_name]]"
+!define PRODUCT_ICON "[[icon]]"
  
 SetCompressor lzma
 
 RequestExecutionLevel admin
 
+[% block modernui %]
 ; Modern UI installer stuff 
 !include "MUI2.nsh"
 !define MUI_ABORTWARNING
@@ -17,13 +25,14 @@ RequestExecutionLevel admin
 !insertmacro MUI_PAGE_INSTFILES
 !insertmacro MUI_PAGE_FINISH
 !insertmacro MUI_LANGUAGE "English"
-; MUI end ------
+[% endblock modernui %]
 
 Name "${PRODUCT_NAME} ${PRODUCT_VERSION}"
 OutFile "${INSTALLER_NAME}"
 InstallDir "$PROGRAMFILES${BITNESS}\${PRODUCT_NAME}"
 ShowInstDetails show
 
+[% block sections %]
 Section -SETTINGS
   SetOutPath "$INSTDIR"
   SetOverwrite ifnewer
@@ -37,9 +46,6 @@ Section "Python ${PY_VERSION}" sec_py
   Delete $INSTDIR\python-${PY_VERSION}${ARCH_TAG}.msi
 SectionEnd
 
-;PYLAUNCHER_INSTALL
-;------------------
-
 Section "!${PRODUCT_NAME}" sec_app
   SectionIn RO
   SetShellVarContext all
@@ -47,9 +53,41 @@ Section "!${PRODUCT_NAME}" sec_app
   SetOutPath "$INSTDIR\pkgs"
   File /r "pkgs\*.*"
   SetOutPath "$INSTDIR"
-  ;INSTALL_FILES
-  ;INSTALL_DIRECTORIES
-  ;INSTALL_SHORTCUTS
+  
+  ; Install files
+  [% for destination, group in grouped_files %]
+    SetOutPath "[[destination]]"
+    [% for file, _ in group %]
+      File "[[file]]"
+    [% endfor %]
+  [% endfor %]
+  
+  ; Install directories
+  [% for dir, destination in ib.install_dirs %]
+    SetOutPath "[[ pjoin(destination, dir) ]]"
+    File /r "[[dir]]\*.*"
+  [% endfor %]
+  
+  [% block install_shortcuts %]
+  ; Install shortcuts
+  ; The output path becomes the working directory for shortcuts
+  SetOutPath "%HOMEDRIVE%\%HOMEPATH%"
+  [% if single_shortcut %]
+    [% for scname, sc in ib.shortcuts.items() %]
+      CreateShortCut "$SMPROGRAMS\[[scname]].lnk" "[[sc['target'] ]]" \
+        '"$INSTDIR\[[ sc['parameters'] ]]"' "$INSTDIR\[[ sc['icon'] ]]"
+    [% endfor %]
+  [% else %]
+    [# Multiple shortcuts: create a directory for them #]
+    CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
+    [% for scname, sc in ib.shortcuts.items() %]
+      CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\[[scname]].lnk" "[[sc['target'] ]]" \
+        '"$INSTDIR\[[ sc['parameters'] ]]"' "$INSTDIR\[[ sc['icon'] ]]"
+    [% endfor %]
+  [% endif %]
+  SetOutPath "$INSTDIR"
+  [% endblock install_shortcuts %]
+  
   ; Byte-compile Python files.
   DetailPrint "Byte-compiling Python modules..."
   nsExec::ExecToLog 'py -${PY_QUALIFIER} -m compileall -q "$INSTDIR\pkgs"'
@@ -74,13 +112,30 @@ Section "Uninstall"
   Delete $INSTDIR\uninstall.exe
   Delete "$INSTDIR\${PRODUCT_ICON}"
   RMDir /r "$INSTDIR\pkgs"
-  ;UNINSTALL_FILES
-  ;UNINSTALL_DIRECTORIES
-  ;UNINSTALL_SHORTCUTS
+  ; Uninstall files
+  [% for file, destination in ib.install_files %]
+    Delete "[[pjoin(destination, file)]]"
+  [% endfor %]
+  ; Uninstall directories
+  [% for dir, destination in ib.install_dirs %]
+    RMDir /r "[[pjoin(destination, dir)]]"
+  [% endfor %]
+  [% block uninstall_shortcuts %]
+  ; Uninstall shortcuts
+  [% if single_shortcut %]
+    [% for scname in ib.shortcuts %]
+      Delete "$SMPROGRAMS\[[scname]].lnk"
+    [% endfor %]
+  [% else %]
+    RMDir /r "$SMPROGRAMS\${PRODUCT_NAME}"
+  [% endif %]
+  [% endblock uninstall_shortcuts %]
   RMDir $INSTDIR
   DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}"
 SectionEnd
 
+[% endblock sections %]
+
 ; Functions
 
 Function .onMouseOverSection
@@ -88,13 +143,13 @@ Function .onMouseOverSection
     FindWindow $R0 "#32770" "" $HWNDPARENT
     GetDlgItem $R0 $R0 1043 ; description item (must be added to the UI)
 
+    [% block mouseover_messages %]
     StrCmp $0 ${sec_py} 0 +2
       SendMessage $R0 ${WM_SETTEXT} 0 "STR:The Python interpreter. \
             This is required for ${PRODUCT_NAME} to run."
-    ;
-    ;PYLAUNCHER_HELP
-    ;------------------
 
     StrCmp $0 ${sec_app} "" +2
       SendMessage $R0 ${WM_SETTEXT} 0 "STR:${PRODUCT_NAME}"
+    
+    [% endblock mouseover_messages %]
 FunctionEnd

+ 20 - 0
nsist/pyapp_w_pylauncher.nsi

@@ -0,0 +1,20 @@
+[% extends pyapp.nsi %]
+[# For Python 2, add the py/pyw Windows launcher. Python 3 includes it already. #]
+
+[% block sections %]
+[[ super() ]]
+
+Section "PyLauncher" sec_pylauncher
+    File "launchwin${ARCH_TAG}.msi",
+    ExecWait 'msiexec /i "$INSTDIR\launchwin${ARCH_TAG}.msi" /qb ALLUSERS=1'
+    Delete "$INSTDIR\launchwin${ARCH_TAG}.msi"
+SectionEnd
+[% endblock %]
+
+[% block mouseover_messages %]
+[[ super() ]]
+
+StrCmp $0 ${sec_app} "" +2
+  SendMessage $R0 ${WM_SETTEXT} 0 "STR:The Python launcher. \
+      This is required for ${PRODUCT_NAME} to run."
+[% endblock %]

+ 1 - 1
setup.py

@@ -55,7 +55,7 @@ setup(name='pynsist',
       author_email='thomas@kluyver.me.uk',
       url='https://github.com/takluyver/pynsist',
       packages=['nsist'],
-      package_data={'nsist': ['template.nsi',
+      package_data={'nsist': ['pyapp.nsi',
                               'glossyorb.ico',
                              ]
                     },