diff --git a/python_appimage/__main__.py b/python_appimage/__main__.py index 9b2340c..a59fcb4 100644 --- a/python_appimage/__main__.py +++ b/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, diff --git a/python_appimage/appimage/build.py b/python_appimage/appimage/build.py index 998f340..de9962c 100644 --- a/python_appimage/appimage/build.py +++ b/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 diff --git a/python_appimage/appimage/relocate.py b/python_appimage/appimage/relocate.py index e4ac1a1..6923d31 100644 --- a/python_appimage/appimage/relocate.py +++ b/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 diff --git a/python_appimage/commands/build/app.py b/python_appimage/commands/build/app.py new file mode 100644 index 0000000..eb378f8 --- /dev/null +++ b/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)) diff --git a/python_appimage/data/apprun.sh b/python_appimage/data/apprun.sh index 513d51b..eb5c31e 100755 --- a/python_appimage/data/apprun.sh +++ b/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 }} diff --git a/python_appimage/utils/compat.py b/python_appimage/utils/compat.py new file mode 100644 index 0000000..faa11e6 --- /dev/null +++ b/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) diff --git a/python_appimage/utils/system.py b/python_appimage/utils/system.py index 8b6cbb2..ee471e9 100644 --- a/python_appimage/utils/system.py +++ b/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') diff --git a/python_appimage/utils/template.py b/python_appimage/utils/template.py new file mode 100644 index 0000000..8321fd0 --- /dev/null +++ b/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) diff --git a/python_appimage/utils/url.py b/python_appimage/utils/url.py index 59c3cf7..8f24c6a 100644 --- a/python_appimage/utils/url.py +++ b/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): diff --git a/setup.py b/setup.py index 103ceb5..8fc66a1 100644 --- a/setup.py +++ b/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 = '''\