app.py 12 KB

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