__init__.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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. import configparser
  10. PY2 = sys.version_info[0] == 2
  11. if PY2:
  12. from urllib import urlretrieve
  13. else:
  14. from urllib.request import urlretrieve
  15. if os.name == 'nt' and PY2:
  16. import _winreg as winreg
  17. elif os.name == 'nt':
  18. import winreg
  19. else:
  20. winreg = None
  21. from .copymodules import copy_modules
  22. from .nsiswriter import NSISFileWriter
  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. def fetch_python(version=DEFAULT_PY_VERSION, bitness=DEFAULT_BITNESS,
  35. destination=DEFAULT_BUILD_DIR):
  36. """Fetch the MSI for the specified version of Python.
  37. It will be placed in the destination directory, and validated using GPG
  38. if possible.
  39. """
  40. arch_tag = '.amd64' if (bitness==64) else ''
  41. url = 'http://python.org/ftp/python/{0}/python-{0}{1}.msi'.format(version, arch_tag)
  42. target = pjoin(destination, 'python-{0}{1}.msi'.format(version, arch_tag))
  43. if os.path.isfile(target):
  44. logger.info('Python MSI already in build directory.')
  45. return
  46. logger.info('Downloading Python MSI...')
  47. urlretrieve(url, target)
  48. urlretrieve(url+'.asc', target+'.asc')
  49. try:
  50. keys_file = os.path.join(_PKGDIR, 'python-pubkeys.txt')
  51. check_output(['gpg', '--import', keys_file])
  52. check_output(['gpg', '--verify', target+'.asc'])
  53. except OSError:
  54. logger.warn("GPG not available - could not check signature of {0}".format(target))
  55. def fetch_pylauncher(bitness=DEFAULT_BITNESS, destination=DEFAULT_BUILD_DIR):
  56. """Fetch the MSI for PyLauncher (required for Python2.x).
  57. It will be placed in the destination directory.
  58. """
  59. arch_tag = '.amd64' if (bitness == 64) else ''
  60. url = ("https://bitbucket.org/vinay.sajip/pylauncher/downloads/"
  61. "launchwin{0}.msi".format(arch_tag))
  62. target = pjoin(destination, 'launchwin{0}.msi'.format(arch_tag))
  63. if os.path.isfile(target):
  64. logger.info('PyLauncher MSI already in build directory.')
  65. return
  66. logger.info('Downloading PyLauncher MSI...')
  67. urlretrieve(url, target)
  68. SCRIPT_TEMPLATE = """#!python{qualifier}
  69. import sys
  70. sys.path.insert(0, 'pkgs')
  71. from {module} import {func}
  72. {func}()
  73. """
  74. def write_script(entrypt, python_version, bitness, target):
  75. """Write a launcher script from a 'module:function' entry point
  76. python_version and bitness are used to write an appropriate shebang line
  77. for the PEP 397 Windows launcher.
  78. """
  79. qualifier = '.'.join(python_version.split('.')[:2])
  80. if bitness == 32:
  81. qualifier += '-32'
  82. module, func = entrypt.split(":")
  83. with open(target, 'w') as f:
  84. f.write(SCRIPT_TEMPLATE.format(qualifier=qualifier, module=module, func=func))
  85. def prepare_shortcuts(shortcuts, py_version, py_bitness, build_dir):
  86. files = set()
  87. for scname, sc in shortcuts.items():
  88. if sc['entry_point']:
  89. sc['script'] = script = scname.replace(' ', '_') + '.py'
  90. write_script(sc['entry_point'], py_version, py_bitness,
  91. pjoin(build_dir, script))
  92. else:
  93. shutil.copy2(sc['script'], build_dir)
  94. shutil.copy2(sc['icon'], build_dir)
  95. sc['icon'] = os.path.basename(sc['icon'])
  96. sc['script'] = os.path.basename(sc['script'])
  97. files.add(sc['script'])
  98. files.add(sc['icon'])
  99. return files
  100. def copy_extra_files(filelist, build_dir):
  101. """Copy a list of files into the build directory.
  102. Returns two lists, files and directories, with only the base filenames
  103. (i.e. no leading path components)
  104. """
  105. files, directories = [], []
  106. for file in filelist:
  107. file = file.rstrip('/\\')
  108. basename = os.path.basename(file)
  109. if os.path.isdir(file):
  110. target_name = pjoin(build_dir, basename)
  111. if os.path.isdir(target_name):
  112. shutil.rmtree(target_name)
  113. elif os.path.exists(target_name):
  114. os.unlink(target_name)
  115. shutil.copytree(file, target_name)
  116. directories.append(basename)
  117. else:
  118. shutil.copy2(file, build_dir)
  119. files.append(basename)
  120. return files, directories
  121. def make_installer_name(appname, version):
  122. """Generate the filename of the installer exe
  123. e.g. My_App_1.0.exe
  124. """
  125. s = appname + '_' + version + '.exe'
  126. return s.replace(' ', '_')
  127. def run_nsis(nsi_file):
  128. """Runs makensis using the specified .nsi file
  129. Returns the exit code.
  130. """
  131. try:
  132. if os.name == 'nt':
  133. makensis = pjoin(winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\NSIS'),
  134. 'makensis.exe')
  135. else:
  136. makensis = 'makensis'
  137. return call([makensis, nsi_file])
  138. except OSError as e:
  139. # This should catch either the registry key or makensis being absent
  140. if e.errno == errno.ENOENT:
  141. print("makensis was not found. Install NSIS and try again.")
  142. print("http://nsis.sourceforge.net/Download")
  143. return 1
  144. def all_steps(appname, version, shortcuts, icon=DEFAULT_ICON,
  145. packages=None, extra_files=None, py_version=DEFAULT_PY_VERSION,
  146. py_bitness=DEFAULT_BITNESS, build_dir=DEFAULT_BUILD_DIR,
  147. installer_name=None, nsi_template=DEFAULT_NSI_TEMPLATE):
  148. """Run all the steps to build an installer.
  149. For details of the parameters, see the documentation for the config file
  150. options.
  151. """
  152. installer_name = installer_name or make_installer_name(appname, version)
  153. try:
  154. os.makedirs(build_dir)
  155. except OSError as e:
  156. if e.errno != errno.EEXIST:
  157. raise e
  158. fetch_python(version=py_version, bitness=py_bitness, destination=build_dir)
  159. if PY2:
  160. fetch_pylauncher(bitness=py_bitness, destination=build_dir)
  161. shortcuts_files = prepare_shortcuts(shortcuts, py_version, py_bitness, build_dir)
  162. # Packages
  163. logger.info("Copying packages into build directory...")
  164. build_pkg_dir = pjoin(build_dir, 'pkgs')
  165. if os.path.isdir(build_pkg_dir):
  166. shutil.rmtree(build_pkg_dir)
  167. if os.path.isdir('pynsist_pkgs'):
  168. shutil.copytree('pynsist_pkgs', build_pkg_dir)
  169. else:
  170. os.mkdir(build_pkg_dir)
  171. copy_modules(packages or [], build_pkg_dir)
  172. nsis_writer = NSISFileWriter(nsi_template,
  173. definitions = {'PRODUCT_NAME': appname,
  174. 'PRODUCT_VERSION': version,
  175. 'PY_VERSION': py_version,
  176. 'PRODUCT_ICON': os.path.basename(icon),
  177. 'INSTALLER_NAME': installer_name,
  178. 'ARCH_TAG': '.amd64' if (py_bitness==64) else '',
  179. }
  180. )
  181. # Extra files
  182. nsis_writer.files, nsis_writer.directories = \
  183. copy_extra_files(extra_files or [], build_dir)
  184. nsis_writer.files.extend(shortcuts_files)
  185. nsis_writer.shortcuts = shortcuts
  186. nsi_file = pjoin(build_dir, 'installer.nsi')
  187. nsis_writer.write(nsi_file)
  188. exitcode = run_nsis(nsi_file)
  189. if not exitcode:
  190. logger.info('Installer written to %s', pjoin(build_dir, installer_name))
  191. def read_shortcuts_config(cfg):
  192. shortcuts = {}
  193. def _check_shortcut(name, sc, section):
  194. if ('entry_point' not in sc) and ('script' not in sc):
  195. raise ValueError('Section {} has neither entry_point nor script.'.format(section))
  196. elif ('entry_point' in sc) and ('script' in sc):
  197. raise ValueError('Section {} has both entry_point and script.'.format(section))
  198. # Copy to a regular dict so it can hold a boolean value
  199. sc2 = dict(sc)
  200. if 'icon' not in sc2:
  201. sc2['icon'] = DEFAULT_ICON
  202. sc2['console'] = sc.getboolean('console', fallback=False)
  203. shortcuts[name] = sc2
  204. for section in cfg.sections():
  205. if section.startswith("Shortcut "):
  206. name = section[len("Shortcut "):]
  207. _check_shortcut(name, cfg[section], section)
  208. appcfg = cfg['Application']
  209. _check_shortcut(appcfg['name'], appcfg, 'Application')
  210. return shortcuts
  211. def read_and_verify_config_file(config_file):
  212. cfg = configparser.ConfigParser()
  213. cfg.read(config_file)
  214. # contains all configuration sections and subsections
  215. # the subsections are a tuple with their name and a boolean, which
  216. # tells us whether the option is mandatory
  217. valid_config_sections = {
  218. 'Application': [
  219. ('name', True),
  220. ('version', True),
  221. ('entry_point', True),
  222. ('script', False),
  223. ('icon', False),
  224. ('console', False),
  225. ],
  226. 'Build': [
  227. ('directory', False),
  228. ('installer_name', False),
  229. ('nsi_template', False),
  230. ],
  231. 'Include': [
  232. ('packages', False),
  233. ('files', False),
  234. ],
  235. 'Python': [
  236. ('version', True),
  237. ('bitness', False),
  238. ],
  239. }
  240. for section in cfg:
  241. # check section names
  242. section_name = str(section)
  243. is_valid_section_name = section_name in valid_config_sections.keys()
  244. if section_name == 'DEFAULT':
  245. # DEFAULT is always inside the config, so just jump over it
  246. continue
  247. if not is_valid_section_name:
  248. err_msg = ("{0} is not a valid section header. Must "
  249. "be one of these: {1}").format(
  250. section_name, ', '.join(valid_section_headers))
  251. raise NameError(err_msg)
  252. # check subsection names
  253. for subsection in cfg[section_name]:
  254. subsection_name = str(subsection)
  255. subsection_names = [s[0] for s in valid_config_sections[section_name]]
  256. is_valid_subsection = subsection_name in subsection_names
  257. if not is_valid_subsection:
  258. err_msg = ("'{0}' is not a valid subsection name for '{1}'. Must "
  259. "be one of these: {2}").format(
  260. subsection_name,
  261. section_name,
  262. ', '.join(subsection_names))
  263. raise NameError(err_msg)
  264. # check mandatory sections
  265. for section_name, subsection_list in valid_config_sections.items():
  266. for subsection_name, mandatory in subsection_list:
  267. if mandatory:
  268. try:
  269. cfg[section_name][subsection_name]
  270. except KeyError:
  271. err_msg = ("The section '{0}' must contain a "
  272. "subsection '{1}'!").format(
  273. section_name,
  274. subsection_name)
  275. raise NameError(err_msg)
  276. return cfg
  277. def main(argv=None):
  278. """Make an installer from the command line.
  279. This parses command line arguments and a config file, and calls
  280. :func:`all_steps` with the extracted information.
  281. """
  282. logger.setLevel(logging.INFO)
  283. logger.addHandler(logging.StreamHandler())
  284. import argparse
  285. argp = argparse.ArgumentParser(prog='pynsist')
  286. argp.add_argument('config_file')
  287. options = argp.parse_args(argv)
  288. dirname, config_file = os.path.split(options.config_file)
  289. if dirname:
  290. os.chdir(dirname)
  291. try:
  292. cfg = read_and_verify_config_file(config_file)
  293. except NameError as e:
  294. logger.error('Error parsing configuration file:')
  295. logger.error(str(e))
  296. sys.exit(1)
  297. appcfg = cfg['Application']
  298. all_steps(
  299. appname = appcfg['name'],
  300. version = appcfg['version'],
  301. icon = appcfg.get('icon', DEFAULT_ICON),
  302. shortcuts = read_shortcuts_config(cfg),
  303. packages = cfg.get('Include', 'packages', fallback='').splitlines(),
  304. extra_files = cfg.get('Include', 'files', fallback='').splitlines(),
  305. py_version = cfg.get('Python', 'version', fallback=DEFAULT_PY_VERSION),
  306. py_bitness = cfg.getint('Python', 'bitness', fallback=DEFAULT_BITNESS),
  307. build_dir = cfg.get('Build', 'directory', fallback=DEFAULT_BUILD_DIR),
  308. installer_name = cfg.get('Build', 'installer_name', fallback=None),
  309. nsi_template = cfg.get('Build', 'nsi_template', fallback=DEFAULT_NSI_TEMPLATE),
  310. )