import os import sys import zipfile from distutils import util from distutils.core import Command from distutils.errors import DistutilsInternalError, DistutilsPlatformError from distutils.dir_util import remove_tree from distutils.sysconfig import get_python_version INNO_MIN_VERSION = '5.1.5' INNO_MAX_VERSION = '5.1.7' PY_SOURCE_EXTS = ('.py', '.pyw') ISCC_TEMPLATE = r""" [Setup] OutputDir=%(output-dir)s OutputBaseFilename=%(output-basename)s Compression=lzma SolidCompression=yes AppName=%(name)s AppVersion=%(version)s AppVerName=%(name)s %(version)s for Python %(target-version)s AppId=%(name)s-%(target-version)s AppPublisher=%(publisher)s AppPublisherURL=%(publisher-url)s AppSupportURL=%(support-url)s UninstallFilesDir=%(uninstall-dir)s DefaultDirName={code:GetDefaultDir} DefaultGroupName={code:GetDefaultGroup} LicenseFile=%(license-file)s UserInfoPage=no DisableReadyMemo=yes DirExistsWarning=no AppendDefaultDirName=no [Types] Name: "full"; Description: "Full installation" Name: "compact"; Description: "Compact installation" Name: "custom"; Description: "Custom installation"; Flags: iscustom [Components] %(components)s %(sections)s [Code] { Define parameters as constants to easy sharing between the script file and the template in BDistInno.py } const GroupName = '%(name)s'; TargetVersion = '%(target-version)s'; var PythonDir, PythonGroup: String; function GetDefaultDir(Param: String): String; begin Result := RemoveBackslashUnlessRoot(PythonDir); end; { GetDefaultDir } function GetDefaultGroup(Param: String): String; begin Result := AddBackslash(PythonGroup) + GroupName; end; { GetDefaultGroup } procedure MutateConfigFile(Filename: String); var Config: String; Prefix: String; begin Filename := ExpandConstant(Filename); LoadStringFromFile(Filename, Config); Prefix := AddBackslash(WizardDirValue()); StringChange(Prefix, '\', '\\') StringChange(Config, '\\PREFIX\\', Prefix); SaveStringToFile(Filename, Config, False); end; { MutateConfigFile } procedure InitializeSelectDirPage; var Page: TWizardPage; Text: TLabel; Top, Left, Width: Integer; begin Page := PageFromID(wpSelectDir); Top := WizardForm.DirEdit.Top + WizardForm.DirEdit.Height + 16; Left := WizardForm.SelectDirBrowseLabel.Left; Width := WizardForm.SelectDirBrowseLabel.Width; Text := TLabel.Create(Page); Text.Parent := Page.Surface; Text.Top := Top; Text.Left := Left; Text.Font.Style := [fsBold]; Text.AutoSize := True; Text.WordWrap := True; Text.Width := Width; Text.Caption := 'Warning: A valid Python ' + TargetVersion + ' installation could not be found.'; Top := Top + Text.Height + 16; Text := TLabel.Create(Page); Text.Parent := Page.Surface; Text.Top := Top; Text.Left := Left; Text.AutoSize := True; Text.WordWrap := True; Text.Width := Width; Text.Caption := 'If you have a custom build of Python installed, select' + ' the folder where it is installed as the installation' + ' location.'; end; { InitializeSelectDirPage } procedure InitializeWizard; begin { Add customizations to the SelectDir page if Python is not found } if PythonDir = '' then InitializeSelectDirPage; end; { InitializeWizard } function InitializeSetup(): Boolean; var Key: String; begin { Get the default installation directory } Key := 'Software\Python\PythonCore\' + TargetVersion + '\InstallPath'; if not RegQueryStringValue(HKEY_CURRENT_USER, Key, '', PythonDir) then RegQueryStringValue(HKEY_LOCAL_MACHINE, Key, '', PythonDir); { Get default Start Menu group } Key := Key + '\InstallGroup'; if not RegQueryStringValue(HKEY_CURRENT_USER, Key, '', PythonGroup) then RegQueryStringValue(HKEY_LOCAL_MACHINE, Key, '', PythonGroup); Result := True; end; { InitializeSetup } function NextButtonClick(CurPage: Integer): Boolean; begin Result := True; if CurPage = wpSelectDir then begin { Check that the install directory is part of PYTHONPATH } end end; { NextButtonClick } """ class Section(object): section_name = None required_parameters = None optional_parameters = ['Languages', 'MinVersion', 'OnlyBelowVersion', 'BeforeInstall', 'AfterInstall'] def __init__(self): assert self.section_name is not None, \ "'section_name' must be defined" assert self.required_parameters is not None, \ "'required_parameters' must be defined" self.entries = [] def addEntry(self, **parameters): entry = [] # Add the required parameters for parameter in self.required_parameters: try: value = parameters[parameter] except KeyError: raise DistutilsInternalError( "missing required parameter '%s'" % parameter) else: del parameters[parameter] entry.append('%s: %s' % (parameter, value)) # Add any optional parameters. for parameter in self.optional_parameters: if parameter in parameters: entry.append('%s: %s' % (parameter, parameters[parameter])) del parameters[parameter] # Any remaining parameters are errors. for parameter in parameters: raise DistutilsInternalError( "unsupported parameter '%s'" % parameter) # Create the entry string and store it. self.entries.append('; '.join(entry)) return class DirsSection(Section): section_name = 'Dirs' required_parameters = ['Name'] optional_parameters = Section.optional_parameters + [ 'Attribs', 'Permissions', 'Flags'] class FilesSection(Section): section_name = 'Files' required_parameters = ['Source', 'DestDir'] optional_parameters = Section.optional_parameters + [ 'DestName', 'Excludes', 'CopyMode', 'Attribs', 'Permissions', 'FontInstall', 'Flags'] class IconsSection(Section): section_name = 'Icons' required_parameters = ['Name', 'Filename'] optional_parameters = Section.optional_parameters + [ 'Parameters', 'WorkingDir', 'HotKey', 'Comment', 'IconFilename', 'IconIndex', 'Flags'] class RunSection(Section): section_name = 'Run' required_parameters = ['Filename'] optional_parameters = Section.optional_parameters + [ 'Description', 'Parameters', 'WorkingDir', 'StatusMsg', 'RunOnceId', 'Flags'] class UninstallDeleteSection(Section): section_name = 'UninstallDelete' required_parameters = ['Type', 'Name'] class Component: section_mapping = { 'Dirs' : DirsSection, 'Files' : FilesSection, 'Icons' : IconsSection, 'Run' : RunSection, 'UninstallDelete' : UninstallDeleteSection, } def __init__(self, name, description, types): self.name = name self.description = description self.types = types self.sections = {} def getEntry(self): return 'Name: "%s"; Description: "%s"; Types: %s' % ( self.name, self.description, self.types) def hasEntries(self): for section in self.sections.itervalues(): if section.entries: return True return False def getSection(self, name): if name not in self.sections: try: section_class = self.section_mapping[name] except KeyError: raise DistutilsInternalError("unknown section '%s'" % name) self.sections[name] = section_class() return self.sections[name] def getSectionEntries(self, name): return [ '%s; Components: %s' %(entry, self.name) for entry in self.getSection(name).entries ] class BDistInno(Command): command_name = 'bdist_inno' description = "create an executable installer for MS Windows" user_options = [ ('bdist-dir=', None, "temporary directory for creating the distribution"), ('keep-temp', 'k', "keep the pseudo-installation tree around after " + "creating the distribution archive"), ('target-version=', None, "require a specific python version on the target system"), ('no-target-compile', 'c', "do not compile .py to .pyc on the target system"), ('no-target-optimize', 'o', "do not compile .py to .pyo (optimized) on the target system"), ('dist-dir=', 'd', "directory to put final built distributions in"), ('skip-build', None, "skip rebuilding everything (for testing/debugging)"), ] boolean_options = ['keep-temp', 'no-target-compile', 'no-target-optimize', 'skip-build'] def initialize_options(self): self.bdist_dir = None self.keep_temp = None self.target_version = None self.no_target_compile = None self.no_target_optimize = None self.dist_dir = None self.skip_build = None self.byte_compile = True return def finalize_options(self): if self.bdist_dir is None: bdist_base = self.get_finalized_command('bdist').bdist_base self.bdist_dir = os.path.join(bdist_base, 'inno') self.set_undefined_options('bdist', ('keep_temp', 'keep_temp'), ('dist_dir', 'dist_dir'), ('skip_build', 'skip_build')) if not self.target_version: self.target_version = '' if not self.skip_build and self.distribution.has_ext_modules(): short_version = get_python_version() if self.target_version and self.target_version != short_version: raise DistutilsOptionError( "target version can only be %s, or the '--skip_build'" " option must be specified" % short_version) self.target_version = short_version self.license_file = self.distribution.license_file if self.license_file: self.license_file = util.convert_path(self.license_file) self.output_basename = '%s.win32' return def run(self): if sys.platform != 'win32': raise DistutilsPlatformError("InnoSetup distributions must be" " created on a Windows platform") # Locate information about Inno Setup from the registry from _winreg import OpenKeyEx, QueryValueEx, HKEY_LOCAL_MACHINE try: key = OpenKeyEx(HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\Windows' r'\CurrentVersion\Uninstall' r'\Inno Setup 5_is1') inno_version = QueryValueEx(key, 'DisplayVersion')[0] inno_path = QueryValueEx(key, 'InstallLocation')[0] except WindowsError: raise DistutilsPlatformError( 'Inno Setup version %s to %s is required to build the' ' installer, but was not found on this system.' % (INNO_MIN_VERSION, INNO_MAX_VERSION)) if inno_version < INNO_MIN_VERSION or inno_version > INNO_MAX_VERSION: raise DistutilsPlatformError( 'Inno Setup version %s to %s is required to build the' ' installer, but version %s was found on this system.' % (INNO_MIN_VERSION, INNO_MAX_VERSION, inno_version)) iss_compiler = os.path.join(inno_path, 'iscc.exe') if not self.skip_build: self.run_command('build') self.mkpath(self.bdist_dir) config = self.reinitialize_command('config') config.cache_filename = None config.prefix = 'PREFIX' config.ensure_finalized() install = self.reinitialize_command('install') install.root = self.bdist_dir install.skip_build = self.skip_build # Always include "compiled" py-files install.compile = install.optimize = self.byte_compile # don't warn about installing into a directory not in sys.path install.warn_dir = False # always include documentation install.with_docs = True self.announce("installing to %s" % self.bdist_dir, 2) install.ensure_finalized() install.run() if self.license_file: self.copy_file(self.license_file, self.bdist_dir) # create the InnoSetup script iss_file = self.build_iss_file() iss_path = os.path.join(self.bdist_dir, '%s.iss' % self.distribution.get_name()) self.announce("writing %r" % iss_path) if not self.dry_run: f = open(iss_path, "w") f.write(iss_file) f.close() # build distribution using the Inno Setup 5 Command-Line Compiler self.announce("creating Inno Setup installer", 2) # log.info self.spawn([iss_compiler, iss_path]) # Add an empty ZIP file to the installer executable to allow for # uploading to PyPI (it checks for the bdist_wininst format). dist_filename = self.get_installer_filename() if os.path.exists(dist_filename) and not self.dry_run: install_egg_info = self.get_finalized_command('install_egg_info') install_dir = install_egg_info.install_dir zip_dir = 'PLATLIB' if install_dir.endswith(os.sep): zip_dir += os.sep zip_file = zipfile.ZipFile(dist_filename, 'a') for filename in install_egg_info.get_outputs(): arcname = filename.replace(install_dir, zip_dir, 1) zip_file.write(filename, arcname) zip_file.close() # Add to 'Distribution.dist_files' so that the "upload" command works if hasattr(self.distribution, 'dist_files'): target_version = self.target_version or 'any' spec = ('bdist_wininst', target_version, dist_filename) self.distribution.dist_files.append(spec) if not self.keep_temp: remove_tree(self.bdist_dir, self.verbose, self.dry_run) return def build_iss_file(self): """Generate the text of an InnoSetup iss file and return it as a list of strings (one per line). """ # [Icons] filespec = 'Source: "%s"; DestDir: "%s"; Components: %s' dirspec = 'Name: "%s"; Components: %s' uninstallspec = 'Type: files; Name: "%s"' iconspec = 'Name: "%s"; Filename: "%s"; Components: %s' runspec = ('Description: "%s"; Filename: "%s"; Components: %s; ' 'Flags: %s') main_component = Component('Main', self.distribution.get_name() + ' Library', 'full compact custom') docs_component = Component('Main\\Documentation', 'Documentation', 'full') test_component = Component('Main\\Testsuite', 'Test suite', 'full') install = self.get_finalized_command('install') for command_name in install.get_sub_commands(): command = self.get_finalized_command(command_name) # Get the mutated outputs split by type. dirs, files, uninstall = self._mutate_outputs(command) # Perform any command-specific processing if command_name == 'install_html': component = docs_component for document in command.documents: flags = getattr(document, 'flags', ()) if 'postinstall' in flags: section = component.getSection('Run') filename = command.get_output_filename(document) filename = self._mutate_filename(filename)[1] section.addEntry( Description='"View %s"' % document.title, Filename='"%s"' % filename, Flags='postinstall shellexec skipifsilent') if 'shortcut' in flags: section = component.getSection('Icons') filename = command.get_output_filename(document) filename = self._mutate_filename(filename)[1] section.addEntry( Name='"{group}\\%s"' % document.title, Filename='"%s"' % filename) elif command_name == 'install_text': component = docs_component elif command_name == 'install_devel': component = test_component elif command_name == 'install_config': component = main_component section = component.getSection('Files') for source, destdir, extra in files: dest = os.path.join(destdir, os.path.basename(source)) extra['AfterInstall'] = "MutateConfigFile('%s')" % dest else: component = main_component if dirs: section = component.getSection('Dirs') for name in dirs: section.addEntry(Name='"%s"' % name) if files: section = component.getSection('Files') for source, destdir, extra in files: section.addEntry(Source='"%s"' % source, DestDir='"%s"' % destdir, Flags='ignoreversion', **extra) if uninstall: section = component.getSection('UninstallDelete') for name in uninstall: section.addEntry(Type='files', Name='"%s"' % name) components = [] sections = {} for component in (main_component, docs_component, test_component): has_entries = False for section in component.sections: entries = component.getSectionEntries(section) if entries: has_entries = True if section not in sections: sections[section] = ['[%s]' % section] sections[section].extend(entries) if has_entries: components.append(component.getEntry()) components = '\n'.join(components) for name in sections: sections[name] = '\n'.join(sections[name]) sections = '\n\n'.join(sections.values()) output_filename = self.get_installer_filename() output_dir, output_basename = os.path.split(output_filename) output_basename = os.path.splitext(output_basename)[0] uninstall_dir = os.path.join(install.install_localstate, 'Uninstall') _, uninstall_dir = self._mutate_filename(uninstall_dir) subst = { 'output-dir' : os.path.abspath(output_dir), 'output-basename' : output_basename, 'name' : self.distribution.get_name(), 'version' : self.distribution.get_version(), 'publisher' : self.distribution.get_author(), 'publisher-url' : self.distribution.get_author_email(), 'support-url' : self.distribution.get_url(), 'uninstall-dir' : uninstall_dir, 'license-file' : os.path.basename(self.license_file or ''), 'target-version' : sys.version[:3], 'custom-page' : self.license_file and 'wpLicense' or 'wpWelcome', 'components' : components, 'sections' : sections, } return ISCC_TEMPLATE % subst def _mutate_filename(self, filename): # Strip the bdist_dir from the filename as the files will be # relative to the setup script which is in bdist_dir. This # is to make the setup script more readable. source = filename[len(self.bdist_dir) + len(os.sep):] # Translate the filename to what is used in the setup script dest = source.replace('PREFIX', '{app}', 1) return source, dest def _mutate_outputs(self, command): dirs = [] files = [] uninstall = [] compile = getattr(command, 'compile', 0) optimize = getattr(command, 'optimize', 0) for filename in command.get_outputs(): source, dest = self._mutate_filename(filename) if os.path.isdir(filename): # An empty directory dirs.append(dest) else: files.append((source, os.path.dirname(dest), {})) # Add uninstall entries for possible bytecode files created # *after* installation. for extension in PY_SOURCE_EXTS: if dest.endswith(extension): barename = dest[:-len(extension)] if not compile: uninstall.append(barename + '.pyc') if not optimize: uninstall.append(barename + '.pyo') return dirs, files, uninstall def get_installer_filename(self): installer_name = '%s.win32' % self.distribution.get_fullname() if self.target_version: # if we create an installer for a specific python version, # include this in the name to match bdist_wininst. installer_name += '-py' + self.target_version return os.path.join(self.dist_dir, installer_name + '.exe')