|
@@ -39,204 +39,210 @@ if os.name == 'nt' and sys.maxsize == (2**63)-1:
|
|
else:
|
|
else:
|
|
DEFAULT_BITNESS = 32
|
|
DEFAULT_BITNESS = 32
|
|
|
|
|
|
-def fetch_python(version=DEFAULT_PY_VERSION, bitness=DEFAULT_BITNESS,
|
|
|
|
- destination=DEFAULT_BUILD_DIR):
|
|
|
|
- """Fetch the MSI for the specified version of Python.
|
|
|
|
|
|
+class InstallerBuilder(object):
|
|
|
|
+ def __init__(self, appname, version, shortcuts, icon=DEFAULT_ICON,
|
|
|
|
+ packages=None, extra_files=None, py_version=DEFAULT_PY_VERSION,
|
|
|
|
+ py_bitness=DEFAULT_BITNESS, build_dir=DEFAULT_BUILD_DIR,
|
|
|
|
+ installer_name=None, nsi_template=DEFAULT_NSI_TEMPLATE):
|
|
|
|
+ self.appname = appname
|
|
|
|
+ self.version = version
|
|
|
|
+ self.shortcuts = shortcuts
|
|
|
|
+ self.icon = icon
|
|
|
|
+ self.packages = packages or []
|
|
|
|
+ self.extra_files = extra_files or []
|
|
|
|
+ self.py_version = py_version
|
|
|
|
+ self.py_bitness = py_bitness
|
|
|
|
+ self.build_dir = build_dir
|
|
|
|
+ self.installer_name = installer_name or self.make_installer_name()
|
|
|
|
+ self.nsi_template = nsi_template
|
|
|
|
+ self.nsi_file = pjoin(self.build_dir, 'installer.nsi')
|
|
|
|
+
|
|
|
|
+ # To be filled later
|
|
|
|
+ self.install_files = []
|
|
|
|
+ self.install_dirs = []
|
|
|
|
|
|
- It will be placed in the destination directory, and validated using GPG
|
|
|
|
- if possible.
|
|
|
|
- """
|
|
|
|
- arch_tag = '.amd64' if (bitness==64) else ''
|
|
|
|
- url = 'http://python.org/ftp/python/{0}/python-{0}{1}.msi'.format(version, arch_tag)
|
|
|
|
- target = pjoin(destination, 'python-{0}{1}.msi'.format(version, arch_tag))
|
|
|
|
- if os.path.isfile(target):
|
|
|
|
- logger.info('Python MSI already in build directory.')
|
|
|
|
- return
|
|
|
|
- logger.info('Downloading Python MSI...')
|
|
|
|
- urlretrieve(url, target)
|
|
|
|
-
|
|
|
|
- urlretrieve(url+'.asc', target+'.asc')
|
|
|
|
- try:
|
|
|
|
- keys_file = os.path.join(_PKGDIR, 'python-pubkeys.txt')
|
|
|
|
- check_output(['gpg', '--import', keys_file])
|
|
|
|
- check_output(['gpg', '--verify', target+'.asc'])
|
|
|
|
- except OSError:
|
|
|
|
- logger.warn("GPG not available - could not check signature of {0}".format(target))
|
|
|
|
-
|
|
|
|
-
|
|
|
|
-
|
|
|
|
-def fetch_pylauncher(bitness=DEFAULT_BITNESS, destination=DEFAULT_BUILD_DIR):
|
|
|
|
- """Fetch the MSI for PyLauncher (required for Python2.x).
|
|
|
|
|
|
+ def make_installer_name(self):
|
|
|
|
+ """Generate the filename of the installer exe
|
|
|
|
+
|
|
|
|
+ e.g. My_App_1.0.exe
|
|
|
|
+ """
|
|
|
|
+ s = self.appname + '_' + self.version + '.exe'
|
|
|
|
+ return s.replace(' ', '_')
|
|
|
|
|
|
- It will be placed in the destination directory.
|
|
|
|
- """
|
|
|
|
- arch_tag = '.amd64' if (bitness == 64) else ''
|
|
|
|
- url = ("https://bitbucket.org/vinay.sajip/pylauncher/downloads/"
|
|
|
|
- "launchwin{0}.msi".format(arch_tag))
|
|
|
|
- target = pjoin(destination, 'launchwin{0}.msi'.format(arch_tag))
|
|
|
|
- if os.path.isfile(target):
|
|
|
|
- logger.info('PyLauncher MSI already in build directory.')
|
|
|
|
- return
|
|
|
|
- logger.info('Downloading PyLauncher MSI...')
|
|
|
|
- urlretrieve(url, target)
|
|
|
|
-
|
|
|
|
-SCRIPT_TEMPLATE = """#!python{qualifier}
|
|
|
|
-import sys, os
|
|
|
|
-scriptdir, script = os.path.split(__file__)
|
|
|
|
-pkgdir = os.path.join(scriptdir, 'pkgs')
|
|
|
|
-sys.path.insert(0, pkgdir)
|
|
|
|
-os.environ['PYTHONPATH'] = pkgdir + os.pathsep + os.environ.get('PYTHONPATH', '')
|
|
|
|
-
|
|
|
|
-def excepthook(etype, value, tb):
|
|
|
|
- "Write unhandled exceptions to a file rather than exiting silently."
|
|
|
|
- import traceback
|
|
|
|
- with open(os.path.join(scriptdir, script+'.log'), 'w') as f:
|
|
|
|
- traceback.print_exception(etype, value, tb, file=f)
|
|
|
|
-sys.excepthook = excepthook
|
|
|
|
-
|
|
|
|
-from {module} import {func}
|
|
|
|
-{func}()
|
|
|
|
-"""
|
|
|
|
-
|
|
|
|
-def write_script(entrypt, python_version, bitness, target):
|
|
|
|
- """Write a launcher script from a 'module:function' entry point
|
|
|
|
|
|
+ def fetch_python(self):
|
|
|
|
+ """Fetch the MSI for the specified version of Python.
|
|
|
|
+
|
|
|
|
+ It will be placed in the destination directory, and validated using GPG
|
|
|
|
+ if possible.
|
|
|
|
+ """
|
|
|
|
+ version = self.py_version
|
|
|
|
+ arch_tag = '.amd64' if (self.py_bitness==64) else ''
|
|
|
|
+ url = 'http://python.org/ftp/python/{0}/python-{0}{1}.msi'.format(version, arch_tag)
|
|
|
|
+ target = pjoin(self.build_dir, 'python-{0}{1}.msi'.format(version, arch_tag))
|
|
|
|
+ if os.path.isfile(target):
|
|
|
|
+ logger.info('Python MSI already in build directory.')
|
|
|
|
+ return
|
|
|
|
+ logger.info('Downloading Python MSI...')
|
|
|
|
+ urlretrieve(url, target)
|
|
|
|
|
|
- python_version and bitness are used to write an appropriate shebang line
|
|
|
|
- for the PEP 397 Windows launcher.
|
|
|
|
- """
|
|
|
|
- qualifier = '.'.join(python_version.split('.')[:2])
|
|
|
|
- if bitness == 32:
|
|
|
|
- qualifier += '-32'
|
|
|
|
- module, func = entrypt.split(":")
|
|
|
|
- with open(target, 'w') as f:
|
|
|
|
- f.write(SCRIPT_TEMPLATE.format(qualifier=qualifier, module=module, func=func))
|
|
|
|
|
|
+ urlretrieve(url+'.asc', target+'.asc')
|
|
|
|
+ try:
|
|
|
|
+ keys_file = os.path.join(_PKGDIR, 'python-pubkeys.txt')
|
|
|
|
+ check_output(['gpg', '--import', keys_file])
|
|
|
|
+ check_output(['gpg', '--verify', target+'.asc'])
|
|
|
|
+ except OSError:
|
|
|
|
+ logger.warn("GPG not available - could not check signature of {0}".format(target))
|
|
|
|
|
|
-def prepare_shortcuts(shortcuts, py_version, py_bitness, build_dir):
|
|
|
|
- files = set()
|
|
|
|
- for scname, sc in shortcuts.items():
|
|
|
|
- if sc.get('entry_point'):
|
|
|
|
- sc['script'] = script = scname.replace(' ', '_') + '.launch.py'
|
|
|
|
- write_script(sc['entry_point'], py_version, py_bitness,
|
|
|
|
- pjoin(build_dir, script))
|
|
|
|
- else:
|
|
|
|
- shutil.copy2(sc['script'], build_dir)
|
|
|
|
-
|
|
|
|
- shutil.copy2(sc['icon'], build_dir)
|
|
|
|
- sc['icon'] = os.path.basename(sc['icon'])
|
|
|
|
- sc['script'] = os.path.basename(sc['script'])
|
|
|
|
- files.add(sc['script'])
|
|
|
|
- files.add(sc['icon'])
|
|
|
|
|
|
+ def fetch_pylauncher(self):
|
|
|
|
+ """Fetch the MSI for PyLauncher (required for Python2.x).
|
|
|
|
|
|
- return files
|
|
|
|
|
|
+ It will be placed in the destination directory.
|
|
|
|
+ """
|
|
|
|
+ arch_tag = '.amd64' if (self.py_bitness == 64) else ''
|
|
|
|
+ url = ("https://bitbucket.org/vinay.sajip/pylauncher/downloads/"
|
|
|
|
+ "launchwin{0}.msi".format(arch_tag))
|
|
|
|
+ target = pjoin(self.build_dir, 'launchwin{0}.msi'.format(arch_tag))
|
|
|
|
+ if os.path.isfile(target):
|
|
|
|
+ logger.info('PyLauncher MSI already in build directory.')
|
|
|
|
+ return
|
|
|
|
+ logger.info('Downloading PyLauncher MSI...')
|
|
|
|
+ urlretrieve(url, target)
|
|
|
|
|
|
-def copy_extra_files(filelist, build_dir):
|
|
|
|
- """Copy a list of files into the build directory.
|
|
|
|
|
|
+ SCRIPT_TEMPLATE = """#!python{qualifier}
|
|
|
|
+ import sys, os
|
|
|
|
+ scriptdir, script = os.path.split(__file__)
|
|
|
|
+ pkgdir = os.path.join(scriptdir, 'pkgs')
|
|
|
|
+ sys.path.insert(0, pkgdir)
|
|
|
|
+ os.environ['PYTHONPATH'] = pkgdir + os.pathsep + os.environ.get('PYTHONPATH', '')
|
|
|
|
|
|
- Returns two lists, files and directories, with only the base filenames
|
|
|
|
- (i.e. no leading path components)
|
|
|
|
- """
|
|
|
|
- files, directories = [], []
|
|
|
|
- for file in filelist:
|
|
|
|
- file = file.rstrip('/\\')
|
|
|
|
- basename = os.path.basename(file)
|
|
|
|
-
|
|
|
|
- if os.path.isdir(file):
|
|
|
|
- target_name = pjoin(build_dir, basename)
|
|
|
|
- if os.path.isdir(target_name):
|
|
|
|
- shutil.rmtree(target_name)
|
|
|
|
- elif os.path.exists(target_name):
|
|
|
|
- os.unlink(target_name)
|
|
|
|
- shutil.copytree(file, target_name)
|
|
|
|
- directories.append(basename)
|
|
|
|
- else:
|
|
|
|
- shutil.copy2(file, build_dir)
|
|
|
|
- files.append(basename)
|
|
|
|
-
|
|
|
|
- return files, directories
|
|
|
|
-
|
|
|
|
-def make_installer_name(appname, version):
|
|
|
|
- """Generate the filename of the installer exe
|
|
|
|
|
|
+ def excepthook(etype, value, tb):
|
|
|
|
+ "Write unhandled exceptions to a file rather than exiting silently."
|
|
|
|
+ import traceback
|
|
|
|
+ with open(os.path.join(scriptdir, script+'.log'), 'w') as f:
|
|
|
|
+ traceback.print_exception(etype, value, tb, file=f)
|
|
|
|
+ sys.excepthook = excepthook
|
|
|
|
|
|
- e.g. My_App_1.0.exe
|
|
|
|
|
|
+ from {module} import {func}
|
|
|
|
+ {func}()
|
|
"""
|
|
"""
|
|
- s = appname + '_' + version + '.exe'
|
|
|
|
- return s.replace(' ', '_')
|
|
|
|
-
|
|
|
|
-def run_nsis(nsi_file):
|
|
|
|
- """Runs makensis using the specified .nsi file
|
|
|
|
|
|
|
|
- Returns the exit code.
|
|
|
|
- """
|
|
|
|
- try:
|
|
|
|
- if os.name == 'nt':
|
|
|
|
- makensis = pjoin(winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\NSIS'),
|
|
|
|
- 'makensis.exe')
|
|
|
|
- else:
|
|
|
|
- makensis = 'makensis'
|
|
|
|
- return call([makensis, nsi_file])
|
|
|
|
- except OSError as e:
|
|
|
|
- # This should catch either the registry key or makensis being absent
|
|
|
|
- if e.errno == errno.ENOENT:
|
|
|
|
- print("makensis was not found. Install NSIS and try again.")
|
|
|
|
- print("http://nsis.sourceforge.net/Download")
|
|
|
|
- return 1
|
|
|
|
|
|
+ def write_script(self, entrypt, target):
|
|
|
|
+ """Write a launcher script from a 'module:function' entry point
|
|
|
|
+
|
|
|
|
+ python_version and bitness are used to write an appropriate shebang line
|
|
|
|
+ for the PEP 397 Windows launcher.
|
|
|
|
+ """
|
|
|
|
+ qualifier = '.'.join(self.py_version.split('.')[:2])
|
|
|
|
+ if self.py_bitness == 32:
|
|
|
|
+ qualifier += '-32'
|
|
|
|
+ module, func = entrypt.split(":")
|
|
|
|
+ with open(target, 'w') as f:
|
|
|
|
+ f.write(self.SCRIPT_TEMPLATE.format(qualifier=qualifier, module=module, func=func))
|
|
|
|
|
|
-def all_steps(appname, version, shortcuts, icon=DEFAULT_ICON,
|
|
|
|
- packages=None, extra_files=None, py_version=DEFAULT_PY_VERSION,
|
|
|
|
- py_bitness=DEFAULT_BITNESS, build_dir=DEFAULT_BUILD_DIR,
|
|
|
|
- installer_name=None, nsi_template=DEFAULT_NSI_TEMPLATE):
|
|
|
|
- """Run all the steps to build an installer.
|
|
|
|
|
|
+ def prepare_shortcuts(self):
|
|
|
|
+ files = set()
|
|
|
|
+ for scname, sc in self.shortcuts.items():
|
|
|
|
+ if sc.get('entry_point'):
|
|
|
|
+ sc['script'] = script = scname.replace(' ', '_') + '.launch.py'
|
|
|
|
+ self.write_script(sc['entry_point'], pjoin(self.build_dir, script))
|
|
|
|
+ else:
|
|
|
|
+ shutil.copy2(sc['script'], self.build_dir)
|
|
|
|
+
|
|
|
|
+ shutil.copy2(sc['icon'], self.build_dir)
|
|
|
|
+ sc['icon'] = os.path.basename(sc['icon'])
|
|
|
|
+ sc['script'] = os.path.basename(sc['script'])
|
|
|
|
+ files.add(sc['script'])
|
|
|
|
+ files.add(sc['icon'])
|
|
|
|
|
|
- For details of the parameters, see the documentation for the config file
|
|
|
|
- options.
|
|
|
|
- """
|
|
|
|
- installer_name = installer_name or make_installer_name(appname, version)
|
|
|
|
|
|
+ self.install_files.extend(files)
|
|
|
|
+
|
|
|
|
+ def prepare_packages(self):
|
|
|
|
+ logger.info("Copying packages into build directory...")
|
|
|
|
+ build_pkg_dir = pjoin(self.build_dir, 'pkgs')
|
|
|
|
+ if os.path.isdir(build_pkg_dir):
|
|
|
|
+ shutil.rmtree(build_pkg_dir)
|
|
|
|
+ if os.path.isdir('pynsist_pkgs'):
|
|
|
|
+ shutil.copytree('pynsist_pkgs', build_pkg_dir)
|
|
|
|
+ else:
|
|
|
|
+ os.mkdir(build_pkg_dir)
|
|
|
|
+ copy_modules(self.packages, build_pkg_dir, py_version=self.py_version)
|
|
|
|
|
|
- try:
|
|
|
|
- os.makedirs(build_dir)
|
|
|
|
- except OSError as e:
|
|
|
|
- if e.errno != errno.EEXIST:
|
|
|
|
- raise e
|
|
|
|
- fetch_python(version=py_version, bitness=py_bitness, destination=build_dir)
|
|
|
|
- if PY2:
|
|
|
|
- fetch_pylauncher(bitness=py_bitness, destination=build_dir)
|
|
|
|
|
|
+ def copy_extra_files(self):
|
|
|
|
+ """Copy a list of files into the build directory, and add them to
|
|
|
|
+ install_files or install_dirs as appropriate.
|
|
|
|
+ """
|
|
|
|
+ for file in self.extra_files:
|
|
|
|
+ file = file.rstrip('/\\')
|
|
|
|
+ basename = os.path.basename(file)
|
|
|
|
|
|
- shortcuts_files = prepare_shortcuts(shortcuts, py_version, py_bitness, build_dir)
|
|
|
|
|
|
+ if os.path.isdir(file):
|
|
|
|
+ target_name = pjoin(self.build_dir, basename)
|
|
|
|
+ if os.path.isdir(target_name):
|
|
|
|
+ shutil.rmtree(target_name)
|
|
|
|
+ elif os.path.exists(target_name):
|
|
|
|
+ os.unlink(target_name)
|
|
|
|
+ shutil.copytree(file, target_name)
|
|
|
|
+ self.install_dirs.append(basename)
|
|
|
|
+ else:
|
|
|
|
+ shutil.copy2(file, self.build_dir)
|
|
|
|
+ self.install_files.append(basename)
|
|
|
|
|
|
- # Packages
|
|
|
|
- logger.info("Copying packages into build directory...")
|
|
|
|
- build_pkg_dir = pjoin(build_dir, 'pkgs')
|
|
|
|
- if os.path.isdir(build_pkg_dir):
|
|
|
|
- shutil.rmtree(build_pkg_dir)
|
|
|
|
- if os.path.isdir('pynsist_pkgs'):
|
|
|
|
- shutil.copytree('pynsist_pkgs', build_pkg_dir)
|
|
|
|
- else:
|
|
|
|
- os.mkdir(build_pkg_dir)
|
|
|
|
- copy_modules(packages or [], build_pkg_dir, py_version=py_version)
|
|
|
|
|
|
+ def write_nsi(self):
|
|
|
|
+ nsis_writer = NSISFileWriter(self.nsi_template, installerbuilder=self,
|
|
|
|
+ definitions = {'PRODUCT_NAME': self.appname,
|
|
|
|
+ 'PRODUCT_VERSION': self.version,
|
|
|
|
+ 'PY_VERSION': self.py_version,
|
|
|
|
+ 'PRODUCT_ICON': os.path.basename(self.icon),
|
|
|
|
+ 'INSTALLER_NAME': self.installer_name,
|
|
|
|
+ 'ARCH_TAG': '.amd64' if (self.py_bitness==64) else '',
|
|
|
|
+ },
|
|
|
|
+ )
|
|
|
|
+
|
|
|
|
+ nsis_writer.write(self.nsi_file)
|
|
|
|
|
|
- nsis_writer = NSISFileWriter(nsi_template,
|
|
|
|
- definitions = {'PRODUCT_NAME': appname,
|
|
|
|
- 'PRODUCT_VERSION': version,
|
|
|
|
- 'PY_VERSION': py_version,
|
|
|
|
- 'PRODUCT_ICON': os.path.basename(icon),
|
|
|
|
- 'INSTALLER_NAME': installer_name,
|
|
|
|
- 'ARCH_TAG': '.amd64' if (py_bitness==64) else '',
|
|
|
|
- }
|
|
|
|
- )
|
|
|
|
- # Extra files
|
|
|
|
- nsis_writer.files, nsis_writer.directories = \
|
|
|
|
- copy_extra_files(extra_files or [], build_dir)
|
|
|
|
-
|
|
|
|
- nsis_writer.files.extend(shortcuts_files)
|
|
|
|
- nsis_writer.shortcuts = shortcuts
|
|
|
|
-
|
|
|
|
- nsi_file = pjoin(build_dir, 'installer.nsi')
|
|
|
|
- nsis_writer.write(nsi_file)
|
|
|
|
|
|
+ def run_nsis(self):
|
|
|
|
+ """Runs makensis using the specified .nsi file
|
|
|
|
+
|
|
|
|
+ Returns the exit code.
|
|
|
|
+ """
|
|
|
|
+ try:
|
|
|
|
+ if os.name == 'nt':
|
|
|
|
+ makensis = pjoin(winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\NSIS'),
|
|
|
|
+ 'makensis.exe')
|
|
|
|
+ else:
|
|
|
|
+ makensis = 'makensis'
|
|
|
|
+ return call([makensis, self.nsi_file])
|
|
|
|
+ except OSError as e:
|
|
|
|
+ # This should catch either the registry key or makensis being absent
|
|
|
|
+ if e.errno == errno.ENOENT:
|
|
|
|
+ print("makensis was not found. Install NSIS and try again.")
|
|
|
|
+ print("http://nsis.sourceforge.net/Download")
|
|
|
|
+ return 1
|
|
|
|
|
|
- exitcode = run_nsis(nsi_file)
|
|
|
|
|
|
+ def run(self):
|
|
|
|
+ """Run all the steps to build an installer.
|
|
|
|
+ """
|
|
|
|
+ try:
|
|
|
|
+ os.makedirs(self.build_dir)
|
|
|
|
+ except OSError as e:
|
|
|
|
+ if e.errno != errno.EEXIST:
|
|
|
|
+ raise e
|
|
|
|
+ self.fetch_python()
|
|
|
|
+ if PY2:
|
|
|
|
+ self.fetch_pylauncher()
|
|
|
|
+
|
|
|
|
+ self.prepare_shortcuts()
|
|
|
|
+
|
|
|
|
+ # Packages
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ # Extra files
|
|
|
|
+ self.copy_extra_files()
|
|
|
|
|
|
- if not exitcode:
|
|
|
|
- logger.info('Installer written to %s', pjoin(build_dir, installer_name))
|
|
|
|
|
|
+ exitcode = self.run_nsis()
|
|
|
|
+
|
|
|
|
+ if not exitcode:
|
|
|
|
+ logger.info('Installer written to %s', pjoin(self.build_dir, self.installer_name))
|
|
|
|
|
|
def read_shortcuts_config(cfg):
|
|
def read_shortcuts_config(cfg):
|
|
|
|
|
|
@@ -271,7 +277,7 @@ def main(argv=None):
|
|
:func:`all_steps` with the extracted information.
|
|
:func:`all_steps` with the extracted information.
|
|
"""
|
|
"""
|
|
logger.setLevel(logging.INFO)
|
|
logger.setLevel(logging.INFO)
|
|
- logger.addHandler(logging.StreamHandler())
|
|
|
|
|
|
+ logger.handlers = [logging.StreamHandler()]
|
|
|
|
|
|
import argparse
|
|
import argparse
|
|
argp = argparse.ArgumentParser(prog='pynsist')
|
|
argp = argparse.ArgumentParser(prog='pynsist')
|
|
@@ -290,7 +296,7 @@ def main(argv=None):
|
|
logger.error(str(e))
|
|
logger.error(str(e))
|
|
sys.exit(1)
|
|
sys.exit(1)
|
|
appcfg = cfg['Application']
|
|
appcfg = cfg['Application']
|
|
- all_steps(
|
|
|
|
|
|
+ InstallerBuilder(
|
|
appname = appcfg['name'],
|
|
appname = appcfg['name'],
|
|
version = appcfg['version'],
|
|
version = appcfg['version'],
|
|
icon = appcfg.get('icon', DEFAULT_ICON),
|
|
icon = appcfg.get('icon', DEFAULT_ICON),
|
|
@@ -302,4 +308,4 @@ def main(argv=None):
|
|
build_dir = cfg.get('Build', 'directory', fallback=DEFAULT_BUILD_DIR),
|
|
build_dir = cfg.get('Build', 'directory', fallback=DEFAULT_BUILD_DIR),
|
|
installer_name = cfg.get('Build', 'installer_name', fallback=None),
|
|
installer_name = cfg.get('Build', 'installer_name', fallback=None),
|
|
nsi_template = cfg.get('Build', 'nsi_template', fallback=DEFAULT_NSI_TEMPLATE),
|
|
nsi_template = cfg.get('Build', 'nsi_template', fallback=DEFAULT_NSI_TEMPLATE),
|
|
- )
|
|
|
|
|
|
+ ).run()
|