Repository: spyder-ide/spyder

Issue:
Migrate mac-application repo to spyder repo
Per conversation https://github.com/spyder-ide/spyder/pull/12011#discussion_r503636816 : move changes from PR spyder-ide/mac-application#3 to this repository.

To be addressed after
1. #12232 
1. #12235 
1. #12236


Discussion:

Resolving patch:
diff --git a/installers/macOS/dmg_settings.py b/installers/macOS/dmg_settings.py
new file mode 100755
--- /dev/null
+++ b/installers/macOS/dmg_settings.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+"""
+Settings file for building the dmg disk image.
+"""
+format = 'UDBZ'
+
+# Volume size
+size = None
+
+# Symlinks to create
+symlinks = {'Applications': '/Applications'}
+
+# Background
+background = 'builtin-arrow'
+
+default_view = 'icon-view'
+
+text_size = 14
+icon_size = 96
diff --git a/installers/macOS/setup.py b/installers/macOS/setup.py
new file mode 100644
--- /dev/null
+++ b/installers/macOS/setup.py
@@ -0,0 +1,251 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Create a stand-alone macOS app using py2app
+
+To be used like this:
+$ python setup.py
+"""
+
+import os
+import sys
+import shutil
+import argparse
+import pkg_resources
+from logging import getLogger, StreamHandler, Formatter
+from setuptools import setup
+from dmgbuild import build_dmg
+from dmgbuild.core import DMGError
+
+# Setup logger
+fmt = Formatter('%(asctime)s [%(levelname)s] [%(name)s] -> %(message)s')
+h = StreamHandler()
+h.setFormatter(fmt)
+logger = getLogger('spyder-macOS')
+logger.addHandler(h)
+logger.setLevel('INFO')
+
+# Setup paths
+HERE = os.path.abspath(__file__)
+THISDIR = os.path.dirname(HERE)
+SPYREPO = os.path.realpath(os.path.join(THISDIR, '..', '..'))
+ICONFILE = os.path.join(SPYREPO, 'img_src', 'spyder.icns')
+
+SPYLINK = os.path.join(THISDIR, 'spyder')
+os.symlink(os.path.join(SPYREPO, 'spyder'), SPYLINK)
+
+from spyder import __version__ as SPYVER                         # noqa
+from spyder.config.utils import EDIT_FILETYPES, _get_extensions  # noqa
+from spyder.config.base import MAC_APP_NAME                      # noqa
+
+# Python version
+PYVER = [sys.version_info.major, sys.version_info.minor,
+         sys.version_info.micro]
+
+
+def make_app_bundle(dist_dir, make_lite=False):
+    """
+    Make macOS application bundle.
+
+    Parameters
+    ----------
+    dist_dir : str
+        Directory in which to put the application bundle.
+    make_lite : bool, optional
+        Whether to create the application bundle with minimal packages.
+        The default is False.
+
+    NOTES
+    -----
+    py2app includes all packages in Spyder.app/Contents/Resources/lib/
+    python<ver>.zip, but some packages have issues when placed there.
+    The following packages are included in py2app's PACKAGES option so that
+    they will be placed in Spyder.app/Contents/Resources/lib/python<ver>
+    instead.
+
+    alabaster :
+        Error message: [Errno 20] Not a directory: '<path>/Resources/lib/
+        python38.zip/alabaster'
+    astroid :
+        ImportError: cannot import name 'context' from 'astroid'
+        (<path>/Resources/lib/python38.zip/astroid/__init__.pyc)
+    ipykernel :
+        ModuleNotFoundError: No module named 'ipykernel.datapub'
+    ipython :
+        [IPKernelApp] WARNING | Could not copy README_STARTUP to startup dir.
+        Source file
+        <path>/Resources/lib/python38.zip/IPython/core/profile/README_STARTUP
+        does not exist
+    jedi :
+        jedi.api.environment.InvalidPythonEnvironment: Could not get version
+        information for '<path>/Contents/MacOS/python': InternalError("The
+        subprocess <path>/Contents/MacOS/python has crashed (EOFError('Ran out
+        of input'), stderr=).")
+    jinja2 :
+        No module named 'jinja2.ext'
+    keyring :
+        ModuleNotFoundError: No module named 'keyring.backends.<mod>'
+    parso :
+        NotADirectoryError: [Errno 20] Not a directory:
+        '<path>/Resources/lib/python38.zip/parso/python/grammar38.txt'
+    PIL :
+        Library not loaded: @loader_path/.dylibs/libjpeg.9.dylib
+        Note: only applicable to not-Lite build
+    pygments :
+        ModuleNotFoundError: No module named 'pygments.formatters.latex'
+    pyls :
+        <path>/Contents/MacOS/python: No module named pyls
+        Note: still occurs in alias mode
+    qtawesome :
+        NotADirectoryError: [Errno 20] Not a directory: '<path>/Resourses/lib/
+        python38.zip/qtawesome/fonts/fontawesome4.7-webfont.ttf'
+    spyder :
+        NotADirectoryError: [Errno 20] Not a directory: '<path>/Resources/lib/
+        python38.zip/spyder/app/mac_stylesheet.qss'
+    spyder_kernels :
+        No module named spyder_kernels.console.__main__
+    sphinx :
+        No module named 'sphinx.builders.changes'
+
+   """
+    build_type = 'lite' if make_lite else 'full'
+    logger.info('Creating %s app bundle...', build_type)
+
+    PACKAGES = ['alabaster', 'astroid', 'ipykernel', 'IPython', 'jedi',
+                'jinja2', 'keyring', 'parso', 'pygments', 'pyls', 'qtawesome',
+                'spyder', 'spyder_kernels', 'sphinx']
+
+    if make_lite:
+        INCLUDES = []
+        EXCLUDES = [
+            'numpy', 'scipy', 'pandas', 'matplotlib', 'cython', 'sympy'
+        ]
+    else:
+        INCLUDES = [
+            'numpy', 'scipy', 'pandas', 'matplotlib', 'cython', 'sympy'
+        ]
+        EXCLUDES = []
+        PACKAGES.append('PIL')
+
+    EDIT_EXT = [ext[1:] for ext in _get_extensions(EDIT_FILETYPES)]
+
+    OPTIONS = {
+        'optimize': 0,
+        'packages': PACKAGES,
+        'includes': INCLUDES,
+        'excludes': EXCLUDES,
+        'iconfile': ICONFILE,
+        'dist_dir': dist_dir,
+        'plist': {
+            'CFBundleDocumentTypes': [{'CFBundleTypeExtensions': EDIT_EXT,
+                                       'CFBundleTypeName': 'Text File',
+                                       'CFBundleTypeRole': 'Editor'}],
+            'CFBundleIdentifier': 'org.spyder-ide',
+            'CFBundleShortVersionString': SPYVER
+        }
+    }
+
+    # Copy main application script
+    app_script_name = MAC_APP_NAME.replace('.app', '.py')
+    app_script_path = os.path.join(SPYREPO, 'scripts', app_script_name)
+    shutil.copy2(os.path.join(SPYREPO, 'scripts', 'spyder'), app_script_path)
+
+    try:
+        setup(app=[app_script_path], options={'py2app': OPTIONS})
+    finally:
+        os.remove(app_script_path)
+
+    # Copy egg info from site-packages: fixes pkg_resources issue for pyls
+    dest_dir = os.path.join(dist_dir, MAC_APP_NAME, 'Contents', 'Resources',
+                            'lib', f'python{PYVER[0]}.{PYVER[1]}')
+    for dist in pkg_resources.working_set:
+        if dist.egg_info is None:
+            continue
+        dest = os.path.join(dest_dir, os.path.basename(dist.egg_info))
+        shutil.copytree(dist.egg_info, dest)
+
+    logger.info('App bundle complete.')
+
+    return
+
+
+def make_disk_image(dist_dir, make_lite=False):
+    """
+    Make macOS disk image containing Spyder.app application bundle.
+
+    Parameters
+    ----------
+    dist_dir : str
+        Directory in which to put the disk image.
+    make_lite : bool, optional
+        Whether to append the disk image file and volume name with 'Lite'.
+        The default is False.
+
+    """
+    logger.info('Creating disk image...')
+
+    volume_name = '{}-{} Py-{}.{}.{}'.format(MAC_APP_NAME[:-4], SPYVER, *PYVER)
+    dmgfile = os.path.join(dist_dir, 'Spyder')
+    if make_lite:
+        volume_name += ' Lite'
+        dmgfile += '-Lite'
+    dmgfile += '.dmg'
+
+    settings_file = os.path.join(THISDIR, 'dmg_settings.py')
+    settings = {
+        'files': [os.path.join(dist_dir, MAC_APP_NAME)],
+        'badge_icon': ICONFILE,
+        'icon_locations': {MAC_APP_NAME: (140, 120),
+                           'Applications': (500, 120)}
+    }
+
+    try:
+        build_dmg(dmgfile, volume_name, settings_file=settings_file,
+                  settings=settings, detach_retries=30)
+        logger.info('Building disk image complete.')
+    except DMGError as exc:
+        if exc.args[0] == 'Unable to detach device cleanly':
+            # don't raise this error since the dmg is forced to detach
+            logger.warning(exc.args[0])
+        else:
+            raise exc
+
+    return
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser()
+    parser.add_argument('-n', '--no-app', dest='make_app',
+                        action='store_false', default=True,
+                        help='Do not create application bundle')
+    parser.add_argument('-l', '--lite', dest='make_lite', action='store_true',
+                        default=False,
+                        help='Build with minimal internal packages')
+    parser.add_argument('-i', '--dmg', dest='make_dmg', action='store_true',
+                        default=False, help='Create disk image')
+    parser.add_argument('-d', '--dist-dir', dest='dist_dir', default='dist',
+                        help='Distribution directory; passed to py2app')
+
+    args, rem = parser.parse_known_args()
+
+    # Groom sys.argv for py2app
+    sys.argv = sys.argv[:1] + ['py2app'] + rem
+
+    dist_dir = os.path.abspath(args.dist_dir)
+
+    try:
+        if args.make_app:
+            make_app_bundle(dist_dir, make_lite=args.make_lite)
+        else:
+            logger.info('Skipping app bundle.')
+    
+        if args.make_dmg:
+            make_disk_image(dist_dir, make_lite=args.make_lite)
+        else:
+            logger.info('Skipping disk image.')
+    finally:
+        os.remove(SPYLINK)


Test patch: