app.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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. args.extra_data
  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):
  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. python_pkg = 'AppDir/opt/python{0:}/lib/python{0:}'.format(
  263. python_version)
  264. dictionary = {'entrypoint': entrypoint,
  265. 'shebang': shebang}
  266. if os.path.exists('AppDir/AppRun'):
  267. os.remove('AppDir/AppRun')
  268. copy_template(PREFIX + '/data/apprun.sh', 'AppDir/AppRun',
  269. **dictionary)
  270. # Build the new AppImage
  271. destination = '{:}-{:}.AppImage'.format(application_name,
  272. platform.machine())
  273. build_appimage(destination=destination)
  274. shutil.move(destination, os.path.join(pwd, destination))