__init__.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. """Build NSIS installers for Python applications.
  2. """
  3. import errno
  4. import logging
  5. import os
  6. import shutil
  7. from subprocess import call
  8. import sys
  9. PY2 = sys.version_info[0] == 2
  10. if os.name == 'nt':
  11. if PY2:
  12. import _winreg as winreg
  13. else:
  14. import winreg
  15. else:
  16. winreg = None
  17. from .copymodules import copy_modules
  18. from .nsiswriter import NSISFileWriter
  19. from .util import download
  20. __version__ = '0.2'
  21. pjoin = os.path.join
  22. logger = logging.getLogger(__name__)
  23. _PKGDIR = os.path.abspath(os.path.dirname(__file__))
  24. DEFAULT_PY_VERSION = '2.7.6' if PY2 else '3.4.0'
  25. DEFAULT_BUILD_DIR = pjoin('build', 'nsis')
  26. DEFAULT_NSI_TEMPLATE = pjoin(_PKGDIR, 'template.nsi')
  27. DEFAULT_ICON = pjoin(_PKGDIR, 'glossyorb.ico')
  28. if os.name == 'nt' and sys.maxsize == (2**63)-1:
  29. DEFAULT_BITNESS = 64
  30. else:
  31. DEFAULT_BITNESS = 32
  32. class InstallerBuilder(object):
  33. def __init__(self, appname, version, shortcuts, icon=DEFAULT_ICON,
  34. packages=None, extra_files=None, py_version=DEFAULT_PY_VERSION,
  35. py_bitness=DEFAULT_BITNESS, build_dir=DEFAULT_BUILD_DIR,
  36. installer_name=None, nsi_template=DEFAULT_NSI_TEMPLATE):
  37. self.appname = appname
  38. self.version = version
  39. self.shortcuts = shortcuts
  40. self.icon = icon
  41. self.packages = packages or []
  42. self.extra_files = extra_files or []
  43. self.py_version = py_version
  44. self.py_bitness = py_bitness
  45. self.build_dir = build_dir
  46. self.installer_name = installer_name or self.make_installer_name()
  47. self.nsi_template = nsi_template
  48. self.nsi_file = pjoin(self.build_dir, 'installer.nsi')
  49. self.py_qualifier = '.'.join(self.py_version.split('.')[:2])
  50. if self.py_bitness == 32:
  51. self.py_qualifier += '-32'
  52. # To be filled later
  53. self.install_files = []
  54. self.install_dirs = []
  55. def make_installer_name(self):
  56. """Generate the filename of the installer exe
  57. e.g. My_App_1.0.exe
  58. """
  59. s = self.appname + '_' + self.version + '.exe'
  60. return s.replace(' ', '_')
  61. def fetch_python(self):
  62. """Fetch the MSI for the specified version of Python.
  63. It will be placed in the destination directory, and validated using GPG
  64. if possible.
  65. """
  66. version = self.py_version
  67. arch_tag = '.amd64' if (self.py_bitness==64) else ''
  68. url = 'https://python.org/ftp/python/{0}/python-{0}{1}.msi'.format(version, arch_tag)
  69. target = pjoin(self.build_dir, 'python-{0}{1}.msi'.format(version, arch_tag))
  70. if os.path.isfile(target):
  71. logger.info('Python MSI already in build directory.')
  72. return
  73. logger.info('Downloading Python MSI...')
  74. download(url, target)
  75. def fetch_pylauncher(self):
  76. """Fetch the MSI for PyLauncher (required for Python2.x).
  77. It will be placed in the destination directory.
  78. """
  79. arch_tag = '.amd64' if (self.py_bitness == 64) else ''
  80. url = ("https://bitbucket.org/vinay.sajip/pylauncher/downloads/"
  81. "launchwin{0}.msi".format(arch_tag))
  82. target = pjoin(self.build_dir, 'launchwin{0}.msi'.format(arch_tag))
  83. if os.path.isfile(target):
  84. logger.info('PyLauncher MSI already in build directory.')
  85. return
  86. logger.info('Downloading PyLauncher MSI...')
  87. download(url, target)
  88. SCRIPT_TEMPLATE = """#!python{qualifier}
  89. import sys, os
  90. scriptdir, script = os.path.split(__file__)
  91. pkgdir = os.path.join(scriptdir, 'pkgs')
  92. sys.path.insert(0, pkgdir)
  93. os.environ['PYTHONPATH'] = pkgdir + os.pathsep + os.environ.get('PYTHONPATH', '')
  94. def excepthook(etype, value, tb):
  95. "Write unhandled exceptions to a file rather than exiting silently."
  96. import traceback
  97. with open(os.path.join(scriptdir, script+'.log'), 'w') as f:
  98. traceback.print_exception(etype, value, tb, file=f)
  99. sys.excepthook = excepthook
  100. from {module} import {func}
  101. {func}()
  102. """
  103. def write_script(self, entrypt, target):
  104. """Write a launcher script from a 'module:function' entry point
  105. python_version and bitness are used to write an appropriate shebang line
  106. for the PEP 397 Windows launcher.
  107. """
  108. module, func = entrypt.split(":")
  109. with open(target, 'w') as f:
  110. f.write(self.SCRIPT_TEMPLATE.format(qualifier=self.py_qualifier,
  111. module=module, func=func))
  112. def prepare_shortcuts(self):
  113. files = set()
  114. for scname, sc in self.shortcuts.items():
  115. if sc.get('entry_point'):
  116. sc['script'] = script = scname.replace(' ', '_') + '.launch.py'
  117. self.write_script(sc['entry_point'], pjoin(self.build_dir, script))
  118. else:
  119. shutil.copy2(sc['script'], self.build_dir)
  120. shutil.copy2(sc['icon'], self.build_dir)
  121. sc['icon'] = os.path.basename(sc['icon'])
  122. sc['script'] = os.path.basename(sc['script'])
  123. files.add(sc['script'])
  124. files.add(sc['icon'])
  125. self.install_files.extend(files)
  126. def prepare_packages(self):
  127. logger.info("Copying packages into build directory...")
  128. build_pkg_dir = pjoin(self.build_dir, 'pkgs')
  129. if os.path.isdir(build_pkg_dir):
  130. shutil.rmtree(build_pkg_dir)
  131. if os.path.isdir('pynsist_pkgs'):
  132. shutil.copytree('pynsist_pkgs', build_pkg_dir)
  133. else:
  134. os.mkdir(build_pkg_dir)
  135. copy_modules(self.packages, build_pkg_dir, py_version=self.py_version)
  136. def copy_extra_files(self):
  137. """Copy a list of files into the build directory, and add them to
  138. install_files or install_dirs as appropriate.
  139. """
  140. for file in self.extra_files:
  141. file = file.rstrip('/\\')
  142. basename = os.path.basename(file)
  143. if os.path.isdir(file):
  144. target_name = pjoin(self.build_dir, basename)
  145. if os.path.isdir(target_name):
  146. shutil.rmtree(target_name)
  147. elif os.path.exists(target_name):
  148. os.unlink(target_name)
  149. shutil.copytree(file, target_name)
  150. self.install_dirs.append(basename)
  151. else:
  152. shutil.copy2(file, self.build_dir)
  153. self.install_files.append(basename)
  154. def write_nsi(self):
  155. nsis_writer = NSISFileWriter(self.nsi_template, installerbuilder=self,
  156. definitions = {'PRODUCT_NAME': self.appname,
  157. 'PRODUCT_VERSION': self.version,
  158. 'PY_VERSION': self.py_version,
  159. 'PY_QUALIFIER': self.py_qualifier,
  160. 'PRODUCT_ICON': os.path.basename(self.icon),
  161. 'INSTALLER_NAME': self.installer_name,
  162. 'ARCH_TAG': '.amd64' if (self.py_bitness==64) else '',
  163. },
  164. )
  165. nsis_writer.write(self.nsi_file)
  166. def run_nsis(self):
  167. """Runs makensis using the specified .nsi file
  168. Returns the exit code.
  169. """
  170. try:
  171. if os.name == 'nt':
  172. makensis = pjoin(winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\NSIS'),
  173. 'makensis.exe')
  174. else:
  175. makensis = 'makensis'
  176. return call([makensis, self.nsi_file])
  177. except OSError as e:
  178. # This should catch either the registry key or makensis being absent
  179. if e.errno == errno.ENOENT:
  180. print("makensis was not found. Install NSIS and try again.")
  181. print("http://nsis.sourceforge.net/Download")
  182. return 1
  183. def run(self):
  184. """Run all the steps to build an installer.
  185. """
  186. try:
  187. os.makedirs(self.build_dir)
  188. except OSError as e:
  189. if e.errno != errno.EEXIST:
  190. raise e
  191. self.fetch_python()
  192. if PY2:
  193. self.fetch_pylauncher()
  194. self.prepare_shortcuts()
  195. # Packages
  196. # Extra files
  197. self.copy_extra_files()
  198. exitcode = self.run_nsis()
  199. if not exitcode:
  200. logger.info('Installer written to %s', pjoin(self.build_dir, self.installer_name))
  201. def main(argv=None):
  202. """Make an installer from the command line.
  203. This parses command line arguments and a config file, and calls
  204. :func:`all_steps` with the extracted information.
  205. """
  206. logger.setLevel(logging.INFO)
  207. logger.handlers = [logging.StreamHandler()]
  208. import argparse
  209. argp = argparse.ArgumentParser(prog='pynsist')
  210. argp.add_argument('config_file')
  211. options = argp.parse_args(argv)
  212. dirname, config_file = os.path.split(options.config_file)
  213. if dirname:
  214. os.chdir(dirname)
  215. try:
  216. from . import configreader
  217. cfg = configreader.read_and_validate(config_file)
  218. shortcuts = configreader.read_shortcuts_config(cfg)
  219. except configreader.InvalidConfig as e:
  220. logger.error('Error parsing configuration file:')
  221. logger.error(str(e))
  222. sys.exit(1)
  223. appcfg = cfg['Application']
  224. InstallerBuilder(
  225. appname = appcfg['name'],
  226. version = appcfg['version'],
  227. icon = appcfg.get('icon', DEFAULT_ICON),
  228. shortcuts = shortcuts,
  229. packages = cfg.get('Include', 'packages', fallback='').splitlines(),
  230. extra_files = cfg.get('Include', 'files', fallback='').splitlines(),
  231. py_version = cfg.get('Python', 'version', fallback=DEFAULT_PY_VERSION),
  232. py_bitness = cfg.getint('Python', 'bitness', fallback=DEFAULT_BITNESS),
  233. build_dir = cfg.get('Build', 'directory', fallback=DEFAULT_BUILD_DIR),
  234. installer_name = cfg.get('Build', 'installer_name', fallback=None),
  235. nsi_template = cfg.get('Build', 'nsi_template', fallback=DEFAULT_NSI_TEMPLATE),
  236. ).run()