فهرست منبع

Add an app builder

Valentin Niess 5 سال پیش
والد
کامیت
f23a2dc25f

+ 7 - 0
python_appimage/__main__.py

@@ -56,6 +56,13 @@ def main():
     build_manylinux_parser.add_argument('--contained', help=argparse.SUPPRESS,
                                         action='store_true', default=False)
 
+    build_app_parser = build_subparsers.add_parser('app',
+        description='Build a Python application using a base AppImage')
+    build_app_parser.add_argument('appdir',
+        help='path to the application metadata')
+    build_app_parser.add_argument('-n', '--name',
+        help='application name')
+
     which_parser = subparsers.add_parser('which',
         description='Locate a binary dependency')
     which_parser.add_argument('binary', choices=binaries,

+ 2 - 5
python_appimage/appimage/build.py

@@ -2,6 +2,7 @@ import os
 import subprocess
 import sys
 
+from ..utils.compat import decode
 from ..utils.deps import APPIMAGETOOL, ensure_appimagetool
 from ..utils.docker import docker_run
 from ..utils.fs import copy_tree
@@ -31,11 +32,7 @@ def build_appimage(appdir=None, destination=None):
                                           stderr=subprocess.STDOUT)
     stdout = []
     while True:
-        out = p.stdout.readline()
-        try:
-            out = out.decode()
-        except AttributeError:
-            out = str(out)
+        out = decode(p.stdout.readline())
         stdout.append(out)
         if out == '' and p.poll() is not None:
             break

+ 5 - 20
python_appimage/appimage/relocate.py

@@ -9,31 +9,15 @@ from ..utils.deps import EXCLUDELIST, PATCHELF, PREFIX, ensure_excludelist,    \
 from ..utils.fs import make_tree, copy_file, copy_tree, remove_file, remove_tree
 from ..utils.log import debug, log
 from ..utils.system import ldd, system
+from ..utils.template import copy_template
 
 
 __all__ = ["patch_binary", "relocate_python"]
 
 
-_template_pattern = re.compile('[{][{]([^{}]+)[}][}]')
-
-
 def _copy_template(name, destination, **kwargs):
-    '''Copy a template file and substitue keywords
-    '''
-    debug('COPY', '%s as %s', name, destination)
-    source = os.path.join(PREFIX, 'data', name)
-    with open(source) as f:
-        template = f.read()
-
-    def matcher(m):
-        return kwargs[m.group(1)]
-
-    txt = _template_pattern.sub(matcher, template)
-
-    with open(destination, 'w') as f:
-        f.write(txt)
-
-    shutil.copymode(source, destination)
+    path = os.path.join(PREFIX, 'data', name)
+    copy_template(path, destination, **kwargs)
 
 
 _excluded_libs = None
@@ -225,7 +209,8 @@ def relocate_python(python=None, appdir=None):
     apprun = APPDIR + '/AppRun'
     if not os.path.exists(apprun):
         log('INSTALL', 'AppRun')
-        _copy_template('apprun.sh', apprun, version=VERSION)
+        entrypoint = '"${{APPDIR}}/usr/bin/python{:}" "$@"'.format(VERSION)
+        _copy_template('apprun.sh', apprun, entrypoint=entrypoint)
 
 
     # Bundle the desktop file

+ 236 - 0
python_appimage/commands/build/app.py

@@ -0,0 +1,236 @@
+import json
+import glob
+import os
+import platform
+import re
+import shutil
+import stat
+import struct
+
+from ...appimage import build_appimage
+from ...utils.compat import decode
+from ...utils.deps import PREFIX
+from ...utils.fs import copy_file, make_tree, remove_file, remove_tree
+from ...utils.log import log
+from ...utils.system import system
+from ...utils.template import copy_template, load_template
+from ...utils.tmp import TemporaryDirectory
+from ...utils.url import urlopen, urlretrieve
+
+
+__all__ = ['execute']
+
+
+def _unpack_args(args):
+    '''Unpack command line arguments
+    '''
+    return args.appdir, args.name
+
+
+_tag_pattern = re.compile('python([^-]+)[-]([^.]+)[.]AppImage')
+
+def execute(appdir, name=None, python_version=None, linux_tag=None,
+            python_tag=None):
+    '''Build a Python application using a base AppImage
+    '''
+
+    # Download releases meta data
+    releases = json.load(
+        urlopen('https://api.github.com/repos/niess/python-appimage/releases'))
+
+
+    # Fetch the requested Python version or the latest if no specific version
+    # was requested
+    release, version = None, '0.0'
+    for entry in releases:
+        tag = entry['tag_name']
+        if not tag.startswith('python'):
+            continue
+        v = tag[6:]
+        if python_version is None:
+            if v > version:
+                release, version = entry, v
+        elif v == python_version:
+            release = entry
+            break
+    if release is None:
+        raise ValueError('could not find base image for Python ' +
+                         python_version)
+    elif python_version is None:
+        python_version = version
+
+
+    # Check for a suitable image
+    if linux_tag is None:
+        linux_tag = 'manylinux1_' + platform.machine()
+
+    if python_tag is None:
+        v = ''.join(version.split('.'))
+        python_tag = 'cp{0:}-cp{0:}'.format(v)
+        if version < '3.8':
+            python_tag += 'm'
+
+    target_tag = '-'.join((python_tag, linux_tag))
+
+    assets = release['assets']
+    for asset in assets:
+        match = _tag_pattern.search(asset['name'])
+        if str(match.group(2)) == target_tag:
+            python_fullversion = str(match.group(1))
+            break
+    else:
+        raise ValueError('Could not find base image for tag ' + target_tag)
+
+    base_image = asset['browser_download_url']
+
+
+    # Set the dictionary for template files
+    dictionary = {
+        'architecture' : platform.machine(),
+        'linux-tag' : linux_tag,
+        'python-executable' : '${APPDIR}/usr/bin/python' + python_version,
+        'python-fullversion' : python_fullversion,
+        'python-tag' : python_tag,
+        'python-version' : python_version
+    }
+
+
+    # Get the list of requirements
+    requirements_list = []
+    requirements_path = appdir + '/requirements.txt'
+    if os.path.exists(requirements_path):
+        with open(requirements_path) as f:
+            for line in f:
+                line = line.strip()
+                if line.startswith('#'):
+                    continue
+                requirements_list.append(line)
+
+    requirements = sorted(requirements_list)
+    n = len(requirements)
+    if n == 0:
+        requirements = ''
+    elif n == 1:
+        requirements = requirements[0]
+    elif n == 2:
+        requirements = ' and '.join(requirements)
+    else:
+        tmp = ', '.join(requirements[:-1])
+        requirements = tmp + ' and ' + requirements[-1]
+    dictionary['requirements'] = requirements
+
+
+    # Build the application
+    appdir = os.path.realpath(appdir)
+    pwd = os.getcwd()
+    with TemporaryDirectory() as tmpdir:
+        application_name = os.path.basename(appdir)
+        application_icon = application_name
+
+        # Extract the base AppImage
+        log('EXTRACT', '%s', os.path.basename(base_image))
+        urlretrieve(base_image, 'base.AppImage')
+        os.chmod('base.AppImage', stat.S_IRWXU)
+        system('./base.AppImage --appimage-extract')
+        system('mv squashfs-root AppDir')
+
+
+        # Bundle the desktop file
+        desktop_path = glob.glob(appdir + '/*.desktop')
+        if desktop_path:
+            desktop_path = desktop_path[0]
+            name = os.path.basename(desktop_path)
+            log('BUNDLE', name)
+
+            python = 'python' + python_fullversion
+            remove_file('AppDir/{:}.desktop'.format(python))
+            remove_file('AppDir/usr/share/applications/{:}.desktop'.format(
+                        python))
+
+            relpath = 'usr/share/applications/' + name
+            copy_template(desktop_path, 'AppDir/' + relpath, **dictionary)
+            os.symlink(relpath, 'AppDir/' + name)
+
+            with open('AppDir/' + relpath) as f:
+                for line in f:
+                    if line.startswith('Name='):
+                        application_name = line[5:].strip()
+                    elif line.startswith('Icon='):
+                        application_icon = line[5:].strip()
+
+
+        # Bundle the application icon
+        icon_paths = glob.glob('{:}/{:}.*'.format(appdir, application_icon))
+        if icon_paths:
+            for icon_path in icon_paths:
+                ext = os.path.splitext(icon_path)[1]
+                if ext in ('.png', '.svg'):
+                    break
+            else:
+                icon_path = None
+        else:
+            icon_path = None
+
+        if icon_path is not None:
+            name = os.path.basename(icon_path)
+            log('BUNDLE', name)
+
+            remove_file('AppDir/python.png')
+            remove_tree('AppDir/usr/share/icons/hicolor/256x256')
+
+            ext = os.path.splitext(name)[1]
+            if ext == '.svg':
+                size = 'scalable'
+            else:
+                with open(icon_path, 'rb') as f:
+                    head = f.read(24)
+                width, height = struct.unpack('>ii', head[16:24])
+                size = '{:}x{:}'.format(width, height)
+
+            relpath = 'usr/share/icons/hicolor/{:}/apps/{:}'.format(size, name)
+            destination = 'AppDir/' + relpath
+            make_tree(os.path.dirname(destination))
+            copy_file(icon_path, destination)
+            os.symlink(relpath, 'AppDir/' + name)
+
+
+        # Bundle any appdata
+        meta_path = glob.glob(appdir + '/*.appdata.xml')
+        if meta_path:
+            meta_path = meta_path[0]
+            name = os.path.basename(meta_path)
+            log('BUNDLE', name)
+
+            python = 'python' + python_fullversion
+            remove_file('AppDir/usr/share/metainfo/{:}.appdata.xml'.format(
+                        python))
+
+            relpath = 'usr/share/metainfo/' + name
+            copy_template(meta_path, 'AppDir/' + relpath, **dictionary)
+
+
+        # Bundle the requirements
+        if requirements_list:
+            system('./AppDir/AppRun -m pip install -U '
+                   '--no-warn-script-location pip')
+            for requirement in requirements_list:
+                log('BUNDLE', requirement)
+                system('./AppDir/AppRun -m pip install -U '
+                       '--no-warn-script-location ' + requirement)
+
+
+        # Bundle the entry point
+        entrypoint_path = glob.glob(appdir + '/entrypoint.*')
+        if entrypoint_path:
+            entrypoint_path = entrypoint_path[0]
+            log('BUNDLE', os.path.basename(entrypoint_path))
+            entrypoint = load_template(entrypoint_path, **dictionary)
+            copy_template(PREFIX + '/data/apprun.sh', 'AppDir/AppRun',
+                          entrypoint=entrypoint)
+
+
+        # Build the new AppImage
+        destination = '{:}-{:}.AppImage'.format(application_name,
+                                                platform.machine())
+        build_appimage(destination=destination)
+        shutil.move(destination, os.path.join(pwd, destination))

+ 2 - 2
python_appimage/data/apprun.sh

@@ -5,5 +5,5 @@ self="$(readlink -f -- $0)"
 here="${self%/*}"
 APPDIR="${APPDIR:-${here}}"
 
-# Call the python wrapper
-"${APPDIR}/usr/bin/python{{version}}" "$@"
+# Call the entry point
+{{ entrypoint }}

+ 10 - 0
python_appimage/utils/compat.py

@@ -0,0 +1,10 @@
+__all__ = ['decode']
+
+
+def decode(s):
+    '''Decode Python 3 bytes as str
+    '''
+    try:
+        return s.decode()
+    except AttributeError:
+        return str(s)

+ 3 - 10
python_appimage/utils/system.py

@@ -2,20 +2,13 @@ import os
 import re
 import subprocess
 
+from .compat import decode
 from .log import debug
 
 
 __all__ = ['ldd', 'system']
 
 
-def _decode(s):
-    '''Decode Python 3 bytes as str
-    '''
-    try:
-        return s.decode()
-    except AttributeError:
-        return s
-
 
 def system(*args):
     '''System call with capturing output
@@ -27,13 +20,13 @@ def system(*args):
                          stderr=subprocess.PIPE)
     out, err = p.communicate()
     if err:
-        err = _decode(err)
+        err = decode(err)
         stripped = [line for line in err.split(os.linesep)
                     if line and not line.startswith('fuse: warning:')]
         if stripped:
             raise RuntimeError(err)
 
-    return str(_decode(out).strip())
+    return str(decode(out).strip())
 
 
 _ldd_pattern = re.compile('=> (.+) [(]0x')

+ 41 - 0
python_appimage/utils/template.py

@@ -0,0 +1,41 @@
+import os
+import re
+import shutil
+
+from .fs import make_tree
+from .log import debug
+
+
+__all__ = ['copy_template', 'load_template']
+
+
+_template_pattern = re.compile('[{][{][ ]*([^{} ]+)[ ]*[}][}]')
+
+
+def load_template(path, **kwargs):
+    '''Load a template file and substitue keywords
+    '''
+    with open(path) as f:
+        template = f.read()
+
+    def matcher(m):
+        tag = m.group(1)
+        try:
+            return kwargs[tag]
+        except KeyError:
+            return tag
+
+    return _template_pattern.sub(matcher, template)
+
+
+def copy_template(path, destination, **kwargs):
+    '''Copy a template file and substitue keywords
+    '''
+    txt = load_template(path, **kwargs)
+
+    debug('COPY', '%s as %s', os.path.basename(path), destination)
+    make_tree(os.path.dirname(destination))
+    with open(destination, 'w') as f:
+        f.write(txt)
+
+    shutil.copymode(path, destination)

+ 13 - 1
python_appimage/utils/url.py

@@ -1,4 +1,8 @@
 import os
+try:
+    from urllib.request import urlopen as _urlopen
+except ImportError:
+    from urllib2 import urlopen as _urlopen
 try:
     from urllib.request import urlretrieve as _urlretrieve
 except ImportError:
@@ -8,7 +12,15 @@ except ImportError:
 from .log import debug
 
 
-__all__ = ['urlretrieve']
+__all__ = ['urlopen', 'urlretrieve']
+
+
+def urlopen(url, *args, **kwargs):
+    '''Open a remote file
+    '''
+    baseurl, urlname = os.path.split(url)
+    debug('DOWNLOAD', '%s from %s', baseurl, urlname)
+    return _urlopen(url, *args, **kwargs)
 
 
 def urlretrieve(url, filename=None):

+ 1 - 4
setup.py

@@ -3,12 +3,9 @@ import os
 import setuptools
 import ssl
 import subprocess
-try:
-    from urllib.request import urlopen
-except ImportError:
-    from urllib2 import urlopen
 
 from python_appimage.utils.deps import ensure_excludelist
+from python_appimage.utils.url import urlopen
 
 
 CLASSIFIERS = '''\