__init__.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  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. try:
  10. from pathlib import Path
  11. except ImportError:
  12. from pathlib2 import Path # Backport
  13. import re
  14. import shutil
  15. from subprocess import call
  16. import sys
  17. import fnmatch
  18. import zipfile
  19. PY2 = sys.version_info[0] == 2
  20. if os.name == 'nt':
  21. if PY2:
  22. import _winreg as winreg
  23. else:
  24. import winreg
  25. else:
  26. winreg = None
  27. from .configreader import get_installer_builder_args
  28. from .commands import prepare_bin_directory
  29. from .copymodules import copy_modules
  30. from .nsiswriter import NSISFileWriter
  31. from .pypi import fetch_pypi_wheels
  32. from .util import download, text_types, get_cache_dir
  33. __version__ = '1.11'
  34. pjoin = os.path.join
  35. logger = logging.getLogger(__name__)
  36. _PKGDIR = os.path.abspath(os.path.dirname(__file__))
  37. DEFAULT_PY_VERSION = '2.7.13' if PY2 else '3.6.1'
  38. DEFAULT_BUILD_DIR = pjoin('build', 'nsis')
  39. DEFAULT_ICON = pjoin(_PKGDIR, 'glossyorb.ico')
  40. if os.name == 'nt' and sys.maxsize == (2**63)-1:
  41. DEFAULT_BITNESS = 64
  42. else:
  43. DEFAULT_BITNESS = 32
  44. def find_makensis_win():
  45. """Locate makensis.exe on Windows by querying the registry"""
  46. try:
  47. nsis_install_dir = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\NSIS')
  48. except OSError:
  49. nsis_install_dir = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Wow6432Node\\NSIS')
  50. return pjoin(nsis_install_dir, 'makensis.exe')
  51. class InputError(ValueError):
  52. def __init__(self, param, value, expected):
  53. self.param = param
  54. self.value = value
  55. self.expected = expected
  56. def __str__(self):
  57. return "{e.value!r} is not valid for {e.param}, expected {e.expected}".format(e=self)
  58. class InstallerBuilder(object):
  59. """Controls building an installer. This includes three main steps:
  60. 1. Arranging the necessary files in the build directory.
  61. 2. Filling out the template NSI file to control NSIS.
  62. 3. Running ``makensis`` to build the installer.
  63. :param str appname: Application name
  64. :param str version: Application version
  65. :param dict shortcuts: Dictionary keyed by shortcut name, containing
  66. dictionaries whose keys match the fields of :ref:`shortcut_config`
  67. in the config file
  68. :param str publisher: Publisher name
  69. :param str icon: Path to an icon for the application
  70. :param list packages: List of strings for importable packages to include
  71. :param dict commands: Dictionary keyed by command name, containing dicts
  72. defining the commands, as in the config file.
  73. :param list pypi_wheel_reqs: Package specifications to fetch from PyPI as wheels
  74. :param list extra_files: List of 2-tuples (file, destination) of files to include
  75. :param list exclude: Paths of files to exclude that would otherwise be included
  76. :param str py_version: Full version of Python to bundle
  77. :param int py_bitness: Bitness of bundled Python (32 or 64)
  78. :param str py_format: 'installer' or 'bundled'. Default 'bundled' for Python
  79. >= 3.6, 'installer' for older versions.
  80. :param bool inc_msvcrt: True to include the Microsoft C runtime with 'bundled'
  81. Python. Ignored when py_format='installer'.
  82. :param str build_dir: Directory to run the build in
  83. :param str installer_name: Filename of the installer to produce
  84. :param str nsi_template: Path to a template NSI file to use
  85. """
  86. def __init__(self, appname, version, shortcuts, publisher=None,
  87. icon=DEFAULT_ICON, packages=None, extra_files=None,
  88. py_version=DEFAULT_PY_VERSION, py_bitness=DEFAULT_BITNESS,
  89. py_format=None, inc_msvcrt=True, build_dir=DEFAULT_BUILD_DIR,
  90. installer_name=None, nsi_template=None,
  91. exclude=None, pypi_wheel_reqs=None, commands=None):
  92. self.appname = appname
  93. self.version = version
  94. self.publisher = publisher
  95. self.shortcuts = shortcuts
  96. self.icon = icon
  97. self.packages = packages or []
  98. self.exclude = [os.path.normpath(p) for p in (exclude or [])]
  99. self.extra_files = extra_files or []
  100. self.pypi_wheel_reqs = pypi_wheel_reqs or []
  101. self.commands = commands or {}
  102. # Python options
  103. self.py_version = py_version
  104. if not self._py_version_pattern.match(py_version):
  105. if not os.environ.get('PYNSIST_PY_PRERELEASE'):
  106. raise InputError('py_version', py_version,
  107. "a full Python version like '3.4.0'")
  108. self.py_bitness = py_bitness
  109. if py_bitness not in {32, 64}:
  110. raise InputError('py_bitness', py_bitness, "32 or 64")
  111. self.py_major_version = self.py_qualifier = '.'.join(self.py_version.split('.')[:2])
  112. if self.py_bitness == 32:
  113. self.py_qualifier += '-32'
  114. if py_format is not None:
  115. self.py_format = py_format
  116. elif self.py_version_tuple >= (3, 6):
  117. self.py_format = 'bundled'
  118. else:
  119. self.py_format = 'installer'
  120. if self.py_version_tuple >= (3, 5):
  121. if self.py_format not in {'installer', 'bundled'}:
  122. raise InputError('py_format', self.py_format, "installer or bundled")
  123. else:
  124. if self.py_format != 'installer':
  125. raise InputError('py_format', self.py_format, "installer (for Python < 3.5)")
  126. self.inc_msvcrt = inc_msvcrt
  127. # Build details
  128. self.build_dir = build_dir
  129. self.installer_name = installer_name or self.make_installer_name()
  130. self.nsi_template = nsi_template
  131. if self.nsi_template is None:
  132. if self.py_format == 'bundled':
  133. if self.inc_msvcrt:
  134. self.nsi_template = 'pyapp_msvcrt.nsi'
  135. else:
  136. self.nsi_template = 'pyapp.nsi'
  137. elif self.py_version_tuple < (3, 3):
  138. self.nsi_template = 'pyapp_w_pylauncher.nsi'
  139. else:
  140. self.nsi_template = 'pyapp_installpy.nsi'
  141. self.nsi_file = pjoin(self.build_dir, 'installer.nsi')
  142. # To be filled later
  143. self.install_files = []
  144. self.install_dirs = []
  145. self.msvcrt_files = []
  146. _py_version_pattern = re.compile(r'\d\.\d+\.\d+$')
  147. @property
  148. def py_version_tuple(self):
  149. parts = self.py_version.split('.')
  150. return int(parts[0]), int(parts[1])
  151. def make_installer_name(self):
  152. """Generate the filename of the installer exe
  153. e.g. My_App_1.0.exe
  154. """
  155. s = self.appname + '_' + self.version + '.exe'
  156. return s.replace(' ', '_')
  157. def _python_download_url_filename(self):
  158. version = self.py_version
  159. bitness = self.py_bitness
  160. if self.py_version_tuple >= (3, 5):
  161. if self.py_format == 'bundled':
  162. filename = 'python-{}-embed-{}.zip'.format(version,
  163. 'amd64' if bitness==64 else 'win32')
  164. else:
  165. filename = 'python-{}{}.exe'.format(version,
  166. '-amd64' if bitness==64 else '')
  167. else:
  168. filename = 'python-{0}{1}.msi'.format(version,
  169. '.amd64' if bitness==64 else '')
  170. version_minus_prerelease = re.sub(r'(a|b|rc)\d+$', '', self.py_version)
  171. return 'https://www.python.org/ftp/python/{0}/{1}'.format(
  172. version_minus_prerelease, filename), filename
  173. def fetch_python(self):
  174. """Fetch the MSI for the specified version of Python.
  175. It will be placed in the build directory.
  176. """
  177. url, filename = self._python_download_url_filename()
  178. cache_file = get_cache_dir(ensure_existence=True) / filename
  179. if not cache_file.is_file():
  180. logger.info('Downloading Python installer...')
  181. logger.info('Getting %s', url)
  182. download(url, cache_file)
  183. logger.info('Copying Python installer to build directory')
  184. shutil.copy2(str(cache_file), self.build_dir)
  185. def fetch_python_embeddable(self):
  186. url, filename = self._python_download_url_filename()
  187. cache_file = get_cache_dir(ensure_existence=True) / filename
  188. if not cache_file.is_file():
  189. logger.info('Downloading embeddable Python build...')
  190. logger.info('Getting %s', url)
  191. download(url, cache_file)
  192. logger.info('Unpacking Python...')
  193. python_dir = pjoin(self.build_dir, 'Python')
  194. try:
  195. shutil.rmtree(python_dir)
  196. except OSError as e:
  197. if e.errno != errno.ENOENT:
  198. raise
  199. with zipfile.ZipFile(str(cache_file)) as z:
  200. z.extractall(python_dir)
  201. self.install_dirs.append(('Python', '$INSTDIR'))
  202. def prepare_msvcrt(self):
  203. arch = 'x64' if self.py_bitness == 64 else 'x86'
  204. src = pjoin(_PKGDIR, 'msvcrt', arch)
  205. dst = pjoin(self.build_dir, 'msvcrt')
  206. self.msvcrt_files = sorted(os.listdir(src))
  207. try:
  208. shutil.rmtree(dst)
  209. except OSError as e:
  210. if e.errno != errno.ENOENT:
  211. raise
  212. shutil.copytree(src, dst)
  213. def fetch_pylauncher(self):
  214. """Fetch the MSI for PyLauncher (required for Python2.x).
  215. It will be placed in the build directory.
  216. """
  217. arch_tag = '.amd64' if (self.py_bitness == 64) else ''
  218. url = ("https://bitbucket.org/vinay.sajip/pylauncher/downloads/"
  219. "launchwin{0}.msi".format(arch_tag))
  220. target = pjoin(self.build_dir, 'launchwin{0}.msi'.format(arch_tag))
  221. if os.path.isfile(target):
  222. logger.info('PyLauncher MSI already in build directory.')
  223. return
  224. logger.info('Downloading PyLauncher MSI...')
  225. download(url, target)
  226. SCRIPT_TEMPLATE = """#!python{qualifier}
  227. import sys, os
  228. scriptdir, script = os.path.split(__file__)
  229. pkgdir = os.path.join(scriptdir, 'pkgs')
  230. sys.path.insert(0, pkgdir)
  231. os.environ['PYTHONPATH'] = pkgdir + os.pathsep + os.environ.get('PYTHONPATH', '')
  232. # APPDATA should always be set, but in case it isn't, try user home
  233. # If none of APPDATA, HOME, USERPROFILE or HOMEPATH are set, this will fail.
  234. appdata = os.environ.get('APPDATA', None) or os.path.expanduser('~')
  235. if 'pythonw' in sys.executable:
  236. # Running with no console - send all stdstream output to a file.
  237. kw = {{'errors': 'replace'}} if (sys.version_info[0] >= 3) else {{}}
  238. sys.stdout = sys.stderr = open(os.path.join(appdata, script+'.log'), 'w', **kw)
  239. else:
  240. # In a console. But if the console was started just for this program, it
  241. # will close as soon as we exit, so write the traceback to a file as well.
  242. def excepthook(etype, value, tb):
  243. "Write unhandled exceptions to a file and to stderr."
  244. import traceback
  245. traceback.print_exception(etype, value, tb)
  246. with open(os.path.join(appdata, script+'.log'), 'w') as f:
  247. traceback.print_exception(etype, value, tb, file=f)
  248. sys.excepthook = excepthook
  249. {extra_preamble}
  250. if __name__ == '__main__':
  251. from {module} import {func}
  252. {func}()
  253. """
  254. def write_script(self, entrypt, target, extra_preamble=''):
  255. """Write a launcher script from a 'module:function' entry point
  256. py_version and py_bitness are used to write an appropriate shebang line
  257. for the PEP 397 Windows launcher.
  258. """
  259. module, func = entrypt.split(":")
  260. with open(target, 'w') as f:
  261. f.write(self.SCRIPT_TEMPLATE.format(qualifier=self.py_qualifier,
  262. module=module, func=func, extra_preamble=extra_preamble))
  263. pkg = module.split('.')[0]
  264. if pkg not in self.packages:
  265. self.packages.append(pkg)
  266. def prepare_shortcuts(self):
  267. """Prepare shortcut files in the build directory.
  268. If entry_point is specified, write the script. If script is specified,
  269. copy to the build directory. Prepare target and parameters for these
  270. shortcuts.
  271. Also copies shortcut icons
  272. """
  273. files = set()
  274. for scname, sc in self.shortcuts.items():
  275. if not sc.get('target'):
  276. if sc.get('entry_point'):
  277. sc['script'] = script = scname.replace(' ', '_') + '.launch.py' \
  278. + ('' if sc['console'] else 'w')
  279. specified_preamble = sc.get('extra_preamble', None)
  280. if isinstance(specified_preamble, text_types):
  281. # Filename
  282. extra_preamble = io.open(specified_preamble, encoding='utf-8')
  283. elif specified_preamble is None:
  284. extra_preamble = io.StringIO() # Empty
  285. else:
  286. # Passed a StringIO or similar object
  287. extra_preamble = specified_preamble
  288. self.write_script(sc['entry_point'], pjoin(self.build_dir, script),
  289. extra_preamble.read().rstrip())
  290. else:
  291. shutil.copy2(sc['script'], self.build_dir)
  292. if self.py_format == 'bundled':
  293. target = '$INSTDIR\Python\python{}.exe'
  294. else:
  295. target = 'py{}'
  296. sc['target'] = target.format('' if sc['console'] else 'w')
  297. sc['parameters'] = '"%s"' % ntpath.join('$INSTDIR', sc['script'])
  298. files.add(os.path.basename(sc['script']))
  299. shutil.copy2(sc['icon'], self.build_dir)
  300. sc['icon'] = os.path.basename(sc['icon'])
  301. files.add(sc['icon'])
  302. self.install_files.extend([(f, '$INSTDIR') for f in files])
  303. def prepare_packages(self):
  304. """Move requested packages into the build directory.
  305. If a pynsist_pkgs directory exists, it is copied into the build
  306. directory as pkgs/ . Any packages not already there are found on
  307. sys.path and copied in.
  308. """
  309. logger.info("Copying packages into build directory...")
  310. build_pkg_dir = pjoin(self.build_dir, 'pkgs')
  311. if os.path.isdir(build_pkg_dir):
  312. shutil.rmtree(build_pkg_dir)
  313. # 1. Manually prepared packages
  314. if os.path.isdir('pynsist_pkgs'):
  315. shutil.copytree('pynsist_pkgs', build_pkg_dir)
  316. else:
  317. os.mkdir(build_pkg_dir)
  318. # 2. Wheels from PyPI
  319. fetch_pypi_wheels(self.pypi_wheel_reqs, build_pkg_dir,
  320. py_version=self.py_version, bitness=self.py_bitness)
  321. # 3. Copy importable modules
  322. copy_modules(self.packages, build_pkg_dir,
  323. py_version=self.py_version, exclude=self.exclude)
  324. def prepare_commands(self):
  325. command_dir = Path(self.build_dir) / 'bin'
  326. if command_dir.is_dir():
  327. shutil.rmtree(str(command_dir))
  328. command_dir.mkdir()
  329. prepare_bin_directory(command_dir, self.commands, bitness=self.py_bitness)
  330. self.install_dirs.append((command_dir.name, '$INSTDIR'))
  331. self.extra_files.append((pjoin(_PKGDIR, '_system_path.py'), '$INSTDIR'))
  332. self.extra_files.append((pjoin(_PKGDIR, '_rewrite_shebangs.py'), '$INSTDIR'))
  333. def copytree_ignore_callback(self, directory, files):
  334. """This is being called back by our shutil.copytree call to implement the
  335. 'exclude' feature.
  336. """
  337. ignored = set()
  338. # Filter by file names relative to the build directory
  339. directory = os.path.normpath(directory)
  340. files = [os.path.join(directory, fname) for fname in files]
  341. # Execute all patterns
  342. for pattern in self.exclude:
  343. ignored.update([
  344. os.path.basename(fname)
  345. for fname in fnmatch.filter(files, pattern)
  346. ])
  347. return ignored
  348. def copy_extra_files(self):
  349. """Copy a list of files into the build directory, and add them to
  350. install_files or install_dirs as appropriate.
  351. """
  352. for file, destination in self.extra_files:
  353. file = file.rstrip('/\\')
  354. basename = os.path.basename(file)
  355. if not destination:
  356. destination = '$INSTDIR'
  357. if os.path.isdir(file):
  358. target_name = pjoin(self.build_dir, basename)
  359. if os.path.isdir(target_name):
  360. shutil.rmtree(target_name)
  361. elif os.path.exists(target_name):
  362. os.unlink(target_name)
  363. if self.exclude:
  364. shutil.copytree(file, target_name,
  365. ignore=self.copytree_ignore_callback)
  366. else:
  367. # Don't use our exclude callback if we don't need to,
  368. # as it slows things down.
  369. shutil.copytree(file, target_name)
  370. self.install_dirs.append((basename, destination))
  371. else:
  372. shutil.copy2(file, self.build_dir)
  373. self.install_files.append((basename, destination))
  374. def write_nsi(self):
  375. """Write the NSI file to define the NSIS installer.
  376. Most of the details of this are in the template and the
  377. :class:`nsist.nsiswriter.NSISFileWriter` class.
  378. """
  379. nsis_writer = NSISFileWriter(self.nsi_template, installerbuilder=self)
  380. logger.info('Writing NSI file to %s', self.nsi_file)
  381. # Sort by destination directory, so we can group them effectively
  382. self.install_files.sort(key=operator.itemgetter(1))
  383. nsis_writer.write(self.nsi_file)
  384. def run_nsis(self):
  385. """Runs makensis using the specified .nsi file
  386. Returns the exit code.
  387. """
  388. try:
  389. if os.name == 'nt':
  390. makensis = find_makensis_win()
  391. else:
  392. makensis = 'makensis'
  393. return call([makensis, self.nsi_file])
  394. except OSError as e:
  395. # This should catch either the registry key or makensis being absent
  396. if e.errno == errno.ENOENT:
  397. print("makensis was not found. Install NSIS and try again.")
  398. print("http://nsis.sourceforge.net/Download")
  399. return 1
  400. def run(self, makensis=True):
  401. """Run all the steps to build an installer.
  402. """
  403. try:
  404. os.makedirs(self.build_dir)
  405. except OSError as e:
  406. if e.errno != errno.EEXIST:
  407. raise e
  408. if self.py_format == 'bundled':
  409. self.fetch_python_embeddable()
  410. if self.inc_msvcrt:
  411. self.prepare_msvcrt()
  412. else:
  413. self.fetch_python()
  414. if self.py_version < '3.3':
  415. self.fetch_pylauncher()
  416. self.prepare_shortcuts()
  417. if self.commands:
  418. self.prepare_commands()
  419. # Packages
  420. self.prepare_packages()
  421. # Extra files
  422. self.copy_extra_files()
  423. self.write_nsi()
  424. if makensis:
  425. exitcode = self.run_nsis()
  426. if not exitcode:
  427. logger.info('Installer written to %s', pjoin(self.build_dir, self.installer_name))
  428. def main(argv=None):
  429. """Make an installer from the command line.
  430. This parses command line arguments and a config file, and calls
  431. :func:`all_steps` with the extracted information.
  432. """
  433. logger.setLevel(logging.INFO)
  434. logger.handlers = [logging.StreamHandler()]
  435. import argparse
  436. argp = argparse.ArgumentParser(prog='pynsist')
  437. argp.add_argument('config_file')
  438. argp.add_argument('--no-makensis', action='store_true',
  439. help='Prepare files and folders, stop before calling makensis. For debugging.'
  440. )
  441. options = argp.parse_args(argv)
  442. dirname, config_file = os.path.split(options.config_file)
  443. if dirname:
  444. os.chdir(dirname)
  445. from . import configreader
  446. try:
  447. cfg = configreader.read_and_validate(config_file)
  448. except configreader.InvalidConfig as e:
  449. logger.error('Error parsing configuration file:')
  450. logger.error(str(e))
  451. sys.exit(1)
  452. args = get_installer_builder_args(cfg)
  453. try:
  454. InstallerBuilder(**args).run(makensis=(not options.no_makensis))
  455. except InputError as e:
  456. logger.error("Error in config values:")
  457. logger.error(str(e))
  458. sys.exit(1)