__init__.py 12 KB

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