mirror of
https://github.com/niess/python-appimage.git
synced 2026-03-14 04:10:15 +01:00
Add an app builder
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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
python_appimage/commands/build/app.py
Normal file
236
python_appimage/commands/build/app.py
Normal file
@@ -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))
|
||||
@@ -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
python_appimage/utils/compat.py
Normal file
10
python_appimage/utils/compat.py
Normal file
@@ -0,0 +1,10 @@
|
||||
__all__ = ['decode']
|
||||
|
||||
|
||||
def decode(s):
|
||||
'''Decode Python 3 bytes as str
|
||||
'''
|
||||
try:
|
||||
return s.decode()
|
||||
except AttributeError:
|
||||
return str(s)
|
||||
@@ -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
python_appimage/utils/template.py
Normal file
41
python_appimage/utils/template.py
Normal file
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
5
setup.py
5
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 = '''\
|
||||
|
||||
Reference in New Issue
Block a user