app.py 12 KB

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