__init__.py 11 KB

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