app.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import json
  2. import glob
  3. import os
  4. import platform
  5. import re
  6. import shutil
  7. import stat
  8. import struct
  9. from ...appimage import build_appimage, cert_file_env_string, tcltk_env_string
  10. from ...utils.compat import decode
  11. from ...utils.deps import PREFIX
  12. from ...utils.fs import copy_file, make_tree, remove_file, remove_tree
  13. from ...utils.log import log
  14. from ...utils.system import system
  15. from ...utils.template import copy_template, load_template
  16. from ...utils.tmp import TemporaryDirectory
  17. from ...utils.url import urlopen, urlretrieve
  18. __all__ = ['execute']
  19. def _unpack_args(args):
  20. '''Unpack command line arguments
  21. '''
  22. return args.appdir, args.name, args.python_version, args.linux_tag, \
  23. args.python_tag, args.base_image
  24. _tag_pattern = re.compile('python([^-]+)[-]([^.]+)[.]AppImage')
  25. def execute(appdir, name=None, python_version=None, linux_tag=None,
  26. python_tag=None, base_image=None):
  27. '''Build a Python application using a base AppImage
  28. '''
  29. if base_image is None:
  30. # Download releases meta data
  31. content = urlopen(
  32. 'https://api.github.com/repos/niess/python-appimage/releases') \
  33. .read()
  34. releases = json.loads(content.decode())
  35. # Fetch the requested Python version or the latest if no specific
  36. # version was requested
  37. release, version = None, '0.0'
  38. for entry in releases:
  39. tag = entry['tag_name']
  40. if not tag.startswith('python'):
  41. continue
  42. v = tag[6:]
  43. if python_version is None:
  44. if v > version:
  45. release, version = entry, v
  46. elif v == python_version:
  47. version = python_version
  48. release = entry
  49. break
  50. if release is None:
  51. raise ValueError('could not find base image for Python ' +
  52. python_version)
  53. elif python_version is None:
  54. python_version = version
  55. # Check for a suitable image
  56. if linux_tag is None:
  57. linux_tag = 'manylinux1_' + platform.machine()
  58. if python_tag is None:
  59. v = ''.join(version.split('.'))
  60. python_tag = 'cp{0:}-cp{0:}'.format(v)
  61. if version < '3.8':
  62. python_tag += 'm'
  63. target_tag = '-'.join((python_tag, linux_tag))
  64. assets = release['assets']
  65. for asset in assets:
  66. match = _tag_pattern.search(asset['name'])
  67. if str(match.group(2)) == target_tag:
  68. python_fullversion = str(match.group(1))
  69. break
  70. else:
  71. raise ValueError('Could not find base image for tag ' + target_tag)
  72. base_image = asset['browser_download_url']
  73. else:
  74. match = _tag_pattern.search(base_image)
  75. if match is None:
  76. raise ValueError('Invalide base image ' + base_image)
  77. tag = str(match.group(2))
  78. python_tag, linux_tag = tag.rsplit('-', 1)
  79. python_fullversion = str(match.group(1))
  80. python_version, _ = python_fullversion.rsplit('.', 1)
  81. # Set the dictionary for template files
  82. dictionary = {
  83. 'architecture' : platform.machine(),
  84. 'linux-tag' : linux_tag,
  85. 'python-executable' : '${APPDIR}/usr/bin/python' + python_version,
  86. 'python-fullversion' : python_fullversion,
  87. 'python-tag' : python_tag,
  88. 'python-version' : python_version
  89. }
  90. # Get the list of requirements
  91. requirements_list = []
  92. requirements_path = appdir + '/requirements.txt'
  93. if os.path.exists(requirements_path):
  94. with open(requirements_path) as f:
  95. for line in f:
  96. line = line.strip()
  97. if (not line) or line.startswith('#'):
  98. continue
  99. requirements_list.append(line)
  100. requirements = sorted(os.path.basename(r) for r in requirements_list)
  101. n = len(requirements)
  102. if n == 0:
  103. requirements = ''
  104. elif n == 1:
  105. requirements = requirements[0]
  106. elif n == 2:
  107. requirements = ' and '.join(requirements)
  108. else:
  109. tmp = ', '.join(requirements[:-1])
  110. requirements = tmp + ' and ' + requirements[-1]
  111. dictionary['requirements'] = requirements
  112. # Build the application
  113. appdir = os.path.realpath(appdir)
  114. pwd = os.getcwd()
  115. with TemporaryDirectory() as tmpdir:
  116. application_name = os.path.basename(appdir)
  117. application_icon = application_name
  118. # Extract the base AppImage
  119. log('EXTRACT', '%s', os.path.basename(base_image))
  120. if base_image.startswith('http'):
  121. urlretrieve(base_image, 'base.AppImage')
  122. os.chmod('base.AppImage', stat.S_IRWXU)
  123. base_image = './base.AppImage'
  124. elif not base_image.startswith('/'):
  125. base_image = os.path.join(pwd, base_image)
  126. system((base_image, '--appimage-extract'))
  127. system(('mv', 'squashfs-root', 'AppDir'))
  128. # Bundle the desktop file
  129. desktop_path = glob.glob(appdir + '/*.desktop')
  130. if desktop_path:
  131. desktop_path = desktop_path[0]
  132. name = os.path.basename(desktop_path)
  133. log('BUNDLE', name)
  134. python = 'python' + python_fullversion
  135. remove_file('AppDir/{:}.desktop'.format(python))
  136. remove_file('AppDir/usr/share/applications/{:}.desktop'.format(
  137. python))
  138. relpath = 'usr/share/applications/' + name
  139. copy_template(desktop_path, 'AppDir/' + relpath, **dictionary)
  140. os.symlink(relpath, 'AppDir/' + name)
  141. with open('AppDir/' + relpath) as f:
  142. for line in f:
  143. if line.startswith('Name='):
  144. application_name = line[5:].strip()
  145. elif line.startswith('Icon='):
  146. application_icon = line[5:].strip()
  147. # Bundle the application icon
  148. icon_paths = glob.glob('{:}/{:}.*'.format(appdir, application_icon))
  149. if icon_paths:
  150. for icon_path in icon_paths:
  151. ext = os.path.splitext(icon_path)[1]
  152. if ext in ('.png', '.svg'):
  153. break
  154. else:
  155. icon_path = None
  156. else:
  157. icon_path = None
  158. if icon_path is not None:
  159. name = os.path.basename(icon_path)
  160. log('BUNDLE', name)
  161. remove_file('AppDir/python.png')
  162. remove_tree('AppDir/usr/share/icons/hicolor/256x256')
  163. ext = os.path.splitext(name)[1]
  164. if ext == '.svg':
  165. size = 'scalable'
  166. else:
  167. with open(icon_path, 'rb') as f:
  168. head = f.read(24)
  169. width, height = struct.unpack('>ii', head[16:24])
  170. size = '{:}x{:}'.format(width, height)
  171. relpath = 'usr/share/icons/hicolor/{:}/apps/{:}'.format(size, name)
  172. destination = 'AppDir/' + relpath
  173. make_tree(os.path.dirname(destination))
  174. copy_file(icon_path, destination)
  175. os.symlink(relpath, 'AppDir/' + name)
  176. # Bundle any appdata
  177. meta_path = glob.glob(appdir + '/*.appdata.xml')
  178. if meta_path:
  179. meta_path = meta_path[0]
  180. name = os.path.basename(meta_path)
  181. log('BUNDLE', name)
  182. python = 'python' + python_fullversion
  183. remove_file('AppDir/usr/share/metainfo/{:}.appdata.xml'.format(
  184. python))
  185. relpath = 'usr/share/metainfo/' + name
  186. copy_template(meta_path, 'AppDir/' + relpath, **dictionary)
  187. # Bundle the requirements
  188. if requirements_list:
  189. deprecation = 'DEPRECATION: Python 2.7 reached the end of its life'
  190. system(('./AppDir/AppRun', '-m', 'pip', 'install', '-U',
  191. '--no-warn-script-location', 'pip'), exclude=deprecation)
  192. for requirement in requirements_list:
  193. if requirement.startswith('git+'):
  194. url, name = os.path.split(requirement)
  195. log('BUNDLE', name + ' from ' + url[4:])
  196. else:
  197. log('BUNDLE', requirement)
  198. system(('./AppDir/AppRun', '-m', 'pip', 'install', '-U',
  199. '--no-warn-script-location', requirement),
  200. exclude=(deprecation, ' Running command git clone -q'))
  201. # Bundle the entry point
  202. entrypoint_path = glob.glob(appdir + '/entrypoint.*')
  203. if entrypoint_path:
  204. entrypoint_path = entrypoint_path[0]
  205. log('BUNDLE', os.path.basename(entrypoint_path))
  206. with open(entrypoint_path) as f:
  207. shebang = f.readline().strip()
  208. if not shebang.startswith('#!'):
  209. shebang = '#! /bin/bash'
  210. entrypoint = load_template(entrypoint_path, **dictionary)
  211. python_pkg = 'AppDir/opt/python{0:}/lib/python{0:}'.format(
  212. python_version)
  213. cert_file = '/opt/_internal/certs.pem'
  214. if not os.path.exists('AppDir' + cert_file):
  215. cert_file = None
  216. dictionary = {'entrypoint': entrypoint,
  217. 'shebang': shebang,
  218. 'tcltk-env': tcltk_env_string(python_pkg),
  219. 'cert-file': cert_file_env_string(cert_file)}
  220. copy_template(PREFIX + '/data/apprun.sh', 'AppDir/AppRun',
  221. **dictionary)
  222. # Build the new AppImage
  223. destination = '{:}-{:}.AppImage'.format(application_name,
  224. platform.machine())
  225. build_appimage(destination=destination)
  226. shutil.move(destination, os.path.join(pwd, destination))