__init__.py 14 KB

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