__init__.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. """Build NSIS installers for Python applications.
  2. """
  3. import io
  4. import logging
  5. import ntpath
  6. import operator
  7. import os
  8. from pathlib import Path
  9. import re
  10. import shutil
  11. from subprocess import call
  12. import sys
  13. import fnmatch
  14. import zipfile
  15. if os.name == 'nt':
  16. import winreg
  17. else:
  18. winreg = None
  19. from .configreader import get_installer_builder_args
  20. from .commands import prepare_bin_directory
  21. from .copymodules import copy_modules
  22. from .nsiswriter import NSISFileWriter
  23. from .wheels import WheelGetter
  24. from .util import download, get_cache_dir, normalize_path
  25. __version__ = '2.8'
  26. pjoin = os.path.join
  27. logger = logging.getLogger(__name__)
  28. _PKGDIR = os.path.abspath(os.path.dirname(__file__))
  29. DEFAULT_PY_VERSION = '3.6.3'
  30. DEFAULT_BUILD_DIR = pjoin('build', 'nsis')
  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. def split_entry_point(ep: str):
  51. """Like ep.split(':'), but with extra checks and helpful errors"""
  52. module, _, func = ep.partition(':')
  53. if all([s.isidentifier() for s in module.split('.')]) and func.isidentifier():
  54. return module, func
  55. raise InputError(
  56. 'entry point', ep, "'mod:func', so 'from mod import func' works"
  57. )
  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 extra_wheel_sources: Directory paths to find wheels in.
  75. :type extra_wheel_sources: list of Path objects
  76. :param local_wheels: Glob paths matching wheel files to include
  77. :type local_wheels: list of str
  78. :param list extra_files: List of 2-tuples (file, destination) of files to include
  79. :param list exclude: Paths of files to exclude that would otherwise be included
  80. :param str py_version: Full version of Python to bundle
  81. :param int py_bitness: Bitness of bundled Python (32 or 64)
  82. :param str py_format: (deprecated) 'bundled'. Use Pynsist 1.x for
  83. 'installer' option.
  84. :param bool inc_msvcrt: True to include the Microsoft C runtime with 'bundled'
  85. Python.
  86. :param str build_dir: Directory to run the build in
  87. :param str installer_name: Filename of the installer to produce
  88. :param str nsi_template: Path to a template NSI file to use
  89. """
  90. def __init__(self, appname, version, shortcuts, *, publisher=None,
  91. icon=DEFAULT_ICON, packages=None, extra_files=None,
  92. py_version=DEFAULT_PY_VERSION, py_bitness=DEFAULT_BITNESS,
  93. py_format='bundled', inc_msvcrt=True, build_dir=DEFAULT_BUILD_DIR,
  94. installer_name=None, nsi_template=None,
  95. exclude=None, pypi_wheel_reqs=None, extra_wheel_sources=None,
  96. local_wheels=None, commands=None, license_file=None):
  97. self.appname = appname
  98. self.version = version
  99. self.publisher = publisher
  100. self.shortcuts = shortcuts
  101. self.icon = icon
  102. self.packages = packages or []
  103. self.exclude = [normalize_path(p) for p in (exclude or [])]
  104. self.extra_files = extra_files or []
  105. self.pypi_wheel_reqs = pypi_wheel_reqs or []
  106. self.extra_wheel_sources = extra_wheel_sources or []
  107. self.local_wheels = local_wheels or []
  108. self.commands = commands or {}
  109. self.license_file = license_file
  110. # Python options
  111. self.py_version = py_version
  112. if not self._py_version_pattern.match(py_version):
  113. if not os.environ.get('PYNSIST_PY_PRERELEASE'):
  114. raise InputError('py_version', py_version,
  115. "a full Python version like '3.4.0'")
  116. if self.py_version_tuple < (3, 5):
  117. raise InputError('py_version', py_version,
  118. "Python >= 3.5.0 (use Pynsist 1.x for older Python.")
  119. self.py_bitness = py_bitness
  120. if py_bitness not in {32, 64}:
  121. raise InputError('py_bitness', py_bitness, "32 or 64")
  122. self.py_major_version = self.py_qualifier = '.'.join(self.py_version.split('.')[:2])
  123. if self.py_bitness == 32:
  124. self.py_qualifier += '-32'
  125. if py_format == 'installer':
  126. raise InputError('py_format', py_format, "'bundled' (use Pynsist 1.x for 'installer')")
  127. elif py_format != 'bundled':
  128. raise InputError('py_format', py_format, "'bundled'")
  129. self.inc_msvcrt = inc_msvcrt
  130. # Build details
  131. self.build_dir = build_dir
  132. self.installer_name = installer_name or self.make_installer_name()
  133. self.nsi_template = nsi_template
  134. if self.nsi_template is None:
  135. if self.inc_msvcrt:
  136. self.nsi_template = 'pyapp_msvcrt.nsi'
  137. else:
  138. self.nsi_template = 'pyapp.nsi'
  139. self.nsi_file = pjoin(self.build_dir, 'installer.nsi')
  140. # To be filled later
  141. self.install_files = []
  142. self.install_dirs = []
  143. self.msvcrt_files = []
  144. _py_version_pattern = re.compile(r'\d\.\d+\.\d+$')
  145. @property
  146. def py_version_tuple(self):
  147. parts = self.py_version.split('.')
  148. return int(parts[0]), int(parts[1])
  149. def make_installer_name(self):
  150. """Generate the filename of the installer exe
  151. e.g. My_App_1.0.exe
  152. """
  153. s = self.appname + '_' + self.version + '.exe'
  154. return s.replace(' ', '_')
  155. def _python_download_url_filename(self):
  156. version = self.py_version
  157. bitness = self.py_bitness
  158. filename = 'python-{}-embed-{}.zip'.format(version,
  159. 'amd64' if bitness==64 else 'win32')
  160. version_minus_prerelease = re.sub(r'(a|b|rc)\d+$', '', self.py_version)
  161. return 'https://www.python.org/ftp/python/{0}/{1}'.format(
  162. version_minus_prerelease, filename), filename
  163. def fetch_python_embeddable(self):
  164. """Fetch the embeddable Windows build for the specified Python version
  165. It will be unpacked into the build directory.
  166. In addition, any ``*._pth`` files found therein will have the pkgs path
  167. appended to them.
  168. """
  169. url, filename = self._python_download_url_filename()
  170. cache_file = get_cache_dir(ensure_existence=True) / filename
  171. if not cache_file.is_file():
  172. logger.info('Downloading embeddable Python build...')
  173. logger.info('Getting %s', url)
  174. download(url, cache_file)
  175. logger.info('Unpacking Python...')
  176. python_dir = pjoin(self.build_dir, 'Python')
  177. with zipfile.ZipFile(str(cache_file)) as z:
  178. z.extractall(python_dir)
  179. # Manipulate any *._pth files so the default paths AND pkgs directory
  180. # ends up in sys.path. Please see:
  181. # https://docs.python.org/3/using/windows.html#finding-modules
  182. # for more information.
  183. pth_files = [f for f in os.listdir(python_dir)
  184. if os.path.isfile(pjoin(python_dir, f))
  185. and f.endswith('._pth')]
  186. for pth in pth_files:
  187. with open(pjoin(python_dir, pth), 'a+b') as f:
  188. f.write(b'\r\n..\\pkgs\r\nimport site\r\n')
  189. self.install_dirs.append(('Python', '$INSTDIR'))
  190. def prepare_msvcrt(self):
  191. arch = 'x64' if self.py_bitness == 64 else 'x86'
  192. src = pjoin(_PKGDIR, 'msvcrt', arch)
  193. dst = pjoin(self.build_dir, 'msvcrt')
  194. self.msvcrt_files = sorted(os.listdir(src))
  195. shutil.copytree(src, dst)
  196. SCRIPT_TEMPLATE = """#!python{qualifier}
  197. import sys, os
  198. import site
  199. scriptdir, script = os.path.split(os.path.abspath(__file__))
  200. installdir = scriptdir # for compatibility with commands
  201. pkgdir = os.path.join(scriptdir, 'pkgs')
  202. sys.path.insert(0, pkgdir)
  203. # Ensure .pth files in pkgdir are handled properly
  204. site.addsitedir(pkgdir)
  205. os.environ['PYTHONPATH'] = pkgdir + os.pathsep + os.environ.get('PYTHONPATH', '')
  206. # APPDATA should always be set, but in case it isn't, try user home
  207. # If none of APPDATA, HOME, USERPROFILE or HOMEPATH are set, this will fail.
  208. appdata = os.environ.get('APPDATA', None) or os.path.expanduser('~')
  209. if 'pythonw' in sys.executable:
  210. # Running with no console - send all stdstream output to a file.
  211. kw = {{'errors': 'replace'}} if (sys.version_info[0] >= 3) else {{}}
  212. sys.stdout = sys.stderr = open(os.path.join(appdata, script+'.log'), 'w', **kw)
  213. else:
  214. # In a console. But if the console was started just for this program, it
  215. # will close as soon as we exit, so write the traceback to a file as well.
  216. def excepthook(etype, value, tb):
  217. "Write unhandled exceptions to a file and to stderr."
  218. import traceback
  219. traceback.print_exception(etype, value, tb)
  220. with open(os.path.join(appdata, script+'.log'), 'w') as f:
  221. traceback.print_exception(etype, value, tb, file=f)
  222. sys.excepthook = excepthook
  223. {extra_preamble}
  224. if __name__ == '__main__':
  225. from {module} import {func}
  226. {func}()
  227. """
  228. def write_script(self, entrypt, target, extra_preamble=''):
  229. """Write a launcher script from a 'module:function' entry point
  230. py_version and py_bitness are used to write an appropriate shebang line
  231. for the PEP 397 Windows launcher.
  232. """
  233. module, func = split_entry_point(entrypt)
  234. with open(target, 'w') as f:
  235. f.write(self.SCRIPT_TEMPLATE.format(qualifier=self.py_qualifier,
  236. module=module, func=func, extra_preamble=extra_preamble))
  237. pkg = module.split('.')[0]
  238. if pkg not in self.packages:
  239. self.packages.append(pkg)
  240. def prepare_shortcuts(self):
  241. """Prepare shortcut files in the build directory.
  242. If entry_point is specified, write the script. If script is specified,
  243. copy to the build directory. Prepare target and parameters for these
  244. shortcuts.
  245. Also copies shortcut icons.
  246. """
  247. files = set()
  248. for scname, sc in self.shortcuts.items():
  249. if not sc.get('target'):
  250. if sc.get('entry_point'):
  251. sc['script'] = script = scname.replace(' ', '_') + '.launch.py' \
  252. + ('' if sc['console'] else 'w')
  253. specified_preamble = sc.get('extra_preamble', None)
  254. if isinstance(specified_preamble, str):
  255. # Filename
  256. extra_preamble = io.open(specified_preamble, encoding='utf-8')
  257. elif specified_preamble is None:
  258. extra_preamble = io.StringIO() # Empty
  259. else:
  260. # Passed a StringIO or similar object
  261. extra_preamble = specified_preamble
  262. self.write_script(sc['entry_point'], pjoin(self.build_dir, script),
  263. extra_preamble.read().rstrip())
  264. else:
  265. shutil.copy2(sc['script'], self.build_dir)
  266. target = '$INSTDIR\\Python\\python{}.exe'
  267. sc['target'] = target.format('' if sc['console'] else 'w')
  268. sc['script'] = os.path.basename(sc['script'])
  269. sc['parameters'] = '"%s"' % ntpath.join('$INSTDIR', sc['script'])
  270. files.add(sc['script'])
  271. shutil.copy2(sc['icon'], self.build_dir)
  272. sc['icon'] = os.path.basename(sc['icon'])
  273. files.add(sc['icon'])
  274. self.install_files.extend([(f, '$INSTDIR') for f in files])
  275. def copy_license(self):
  276. """
  277. If a license file has been specified, ensure it's copied into the
  278. install directory and added to the install_files list.
  279. """
  280. if self.license_file:
  281. shutil.copy2(self.license_file, self.build_dir)
  282. license_file_name = os.path.basename(self.license_file)
  283. self.install_files.append((license_file_name, '$INSTDIR'))
  284. def prepare_packages(self):
  285. """Move requested packages into the build directory.
  286. If a pynsist_pkgs directory exists, it is copied into the build
  287. directory as pkgs/ . Any packages not already there are found on
  288. sys.path and copied in.
  289. """
  290. logger.info("Copying packages into build directory...")
  291. build_pkg_dir = pjoin(self.build_dir, 'pkgs')
  292. # 1. Manually prepared packages
  293. if os.path.isdir('pynsist_pkgs'):
  294. shutil.copytree('pynsist_pkgs', build_pkg_dir)
  295. else:
  296. os.mkdir(build_pkg_dir)
  297. # 2. Wheels specified in pypi_wheel_reqs or in paths of local_wheels
  298. wg = WheelGetter(self.pypi_wheel_reqs, self.local_wheels, build_pkg_dir,
  299. py_version=self.py_version, bitness=self.py_bitness,
  300. extra_sources=self.extra_wheel_sources,
  301. exclude=self.exclude)
  302. wg.get_all()
  303. # 3. Copy importable modules
  304. copy_modules(self.packages, build_pkg_dir,
  305. py_version=self.py_version, exclude=self.exclude)
  306. def prepare_commands(self):
  307. for cmd in self.commands.values():
  308. split_entry_point(cmd['entry_point']) # Check entry point format
  309. command_dir = Path(self.build_dir) / 'bin'
  310. command_dir.mkdir()
  311. prepare_bin_directory(command_dir, self.commands, bitness=self.py_bitness)
  312. self.install_dirs.append((command_dir.name, '$INSTDIR'))
  313. self.extra_files.append((pjoin(_PKGDIR, '_system_path.py'), '$INSTDIR'))
  314. def copytree_ignore_callback(self, directory, files):
  315. """This is being called back by our shutil.copytree call to implement the
  316. 'exclude' feature.
  317. """
  318. ignored = set()
  319. # Filter by file names relative to the build directory
  320. directory = os.path.normpath(directory)
  321. files = [os.path.join(directory, fname) for fname in files]
  322. # Execute all patterns
  323. for pattern in self.exclude:
  324. ignored.update([
  325. os.path.basename(fname)
  326. for fname in fnmatch.filter(files, pattern)
  327. ])
  328. return ignored
  329. def copy_extra_files(self):
  330. """Copy a list of files into the build directory, and add them to
  331. install_files or install_dirs as appropriate.
  332. """
  333. # Create installer.nsi, so that a data file with the same name will
  334. # automatically be renamed installer.1.nsi. All the other files needed
  335. # in the build directory should already be in place.
  336. Path(self.nsi_file).touch()
  337. for file, destination in self.extra_files:
  338. file = file.rstrip('/\\')
  339. in_build_dir = Path(self.build_dir, os.path.basename(file))
  340. # Find an unused name in the build directory,
  341. # similar to the source filename, e.g. foo.1.txt, foo.2.txt, ...
  342. stem, suffix = in_build_dir.stem, in_build_dir.suffix
  343. n = 1
  344. while in_build_dir.exists():
  345. name = '{}.{}{}'.format(stem, n, suffix)
  346. in_build_dir = in_build_dir.with_name(name)
  347. n += 1
  348. if destination:
  349. # Normalize destination paths to Windows-style
  350. destination = destination.replace('/', '\\')
  351. else:
  352. destination = '$INSTDIR'
  353. if os.path.isdir(file):
  354. if self.exclude:
  355. shutil.copytree(file, str(in_build_dir),
  356. ignore=self.copytree_ignore_callback)
  357. else:
  358. # Don't use our exclude callback if we don't need to,
  359. # as it slows things down.
  360. shutil.copytree(file, str(in_build_dir))
  361. self.install_dirs.append((in_build_dir.name, destination))
  362. else:
  363. shutil.copy2(file, str(in_build_dir))
  364. self.install_files.append((in_build_dir.name, destination))
  365. def write_nsi(self):
  366. """Write the NSI file to define the NSIS installer.
  367. Most of the details of this are in the template and the
  368. :class:`nsist.nsiswriter.NSISFileWriter` class.
  369. """
  370. nsis_writer = NSISFileWriter(self.nsi_template, installerbuilder=self)
  371. logger.info('Writing NSI file to %s', self.nsi_file)
  372. # Sort by destination directory, so we can group them effectively
  373. self.install_files.sort(key=operator.itemgetter(1))
  374. nsis_writer.write(self.nsi_file)
  375. def run_nsis(self):
  376. """Runs makensis using the specified .nsi file
  377. Returns the exit code.
  378. """
  379. makensis = shutil.which('makensis')
  380. if (makensis is None) and os.name == 'nt':
  381. try:
  382. makensis = find_makensis_win()
  383. except OSError:
  384. pass
  385. if makensis is None:
  386. print("makensis was not found. Install NSIS and try again.")
  387. print("http://nsis.sourceforge.net/Download")
  388. return 1
  389. logger.info('\n~~~ Running makensis ~~~')
  390. return call([makensis, self.nsi_file])
  391. def run(self, makensis=True):
  392. """Run all the steps to build an installer.
  393. """
  394. try: # Start with a clean build directory
  395. shutil.rmtree(self.build_dir)
  396. except FileNotFoundError:
  397. pass
  398. os.makedirs(self.build_dir)
  399. self.fetch_python_embeddable()
  400. if self.inc_msvcrt:
  401. self.prepare_msvcrt()
  402. self.prepare_shortcuts()
  403. self.copy_license()
  404. if self.commands:
  405. self.prepare_commands()
  406. # Packages
  407. self.prepare_packages()
  408. # Extra files
  409. self.copy_extra_files()
  410. self.write_nsi()
  411. if makensis:
  412. exitcode = self.run_nsis()
  413. if not exitcode:
  414. logger.info('Installer written to %s', pjoin(self.build_dir, self.installer_name))
  415. return exitcode
  416. return 0
  417. def main(argv=None):
  418. """Make an installer from the command line.
  419. This parses command line arguments and a config file, and calls
  420. :func:`all_steps` with the extracted information.
  421. """
  422. logger.setLevel(logging.INFO)
  423. logger.handlers = [logging.StreamHandler()]
  424. import argparse
  425. argp = argparse.ArgumentParser(prog='pynsist')
  426. argp.add_argument('config_file')
  427. argp.add_argument('--no-makensis', action='store_true',
  428. help='Prepare files and folders, stop before calling makensis. For debugging.'
  429. )
  430. options = argp.parse_args(argv)
  431. dirname, config_file = os.path.split(options.config_file)
  432. if dirname:
  433. os.chdir(dirname)
  434. from . import configreader
  435. try:
  436. cfg = configreader.read_and_validate(config_file)
  437. args = get_installer_builder_args(cfg)
  438. except configreader.InvalidConfig as e:
  439. logger.error('Error parsing configuration file:')
  440. logger.error(str(e))
  441. sys.exit(1)
  442. try:
  443. ec = InstallerBuilder(**args).run(makensis=(not options.no_makensis))
  444. except InputError as e:
  445. logger.error("Error in config values:")
  446. logger.error(str(e))
  447. sys.exit(1)
  448. return ec