__init__.py 11 KB

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