app.py 11 KB

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