test-appimage.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. #! /usr/bin/env python3
  2. import argparse
  3. from enum import auto, Enum
  4. import inspect
  5. import os
  6. from pathlib import Path
  7. import shutil
  8. import subprocess
  9. import sys
  10. import tempfile
  11. from types import FunctionType
  12. from typing import NamedTuple
  13. from python_appimage.manylinux import PythonVersion
  14. ARGS = None
  15. def assert_eq(expected, found):
  16. if expected != found:
  17. raise AssertionError('expected "{}", found "{}"'.format(
  18. expected, found))
  19. class Script(NamedTuple):
  20. '''Python script wrapper'''
  21. content: str
  22. def run(self, appimage: Path):
  23. '''Run the script through an appimage'''
  24. with tempfile.TemporaryDirectory() as tmpdir:
  25. script = f'{tmpdir}/script.py'
  26. with open(script, 'w') as f:
  27. f.write(inspect.getsource(assert_eq))
  28. f.write(os.linesep)
  29. f.write(self.content)
  30. return system(f'{appimage} {script}')
  31. class Status(Enum):
  32. '''Test exit status'''
  33. FAILED = auto()
  34. SKIPPED = auto()
  35. SUCCESS = auto()
  36. def __str__(self):
  37. return self.name
  38. def system(cmd):
  39. '''Run a system command'''
  40. r = subprocess.run(cmd, capture_output=True, shell=True)
  41. if r.returncode != 0:
  42. raise ValueError(r.stderr.decode())
  43. else:
  44. return r.stdout.decode()
  45. class TestContext:
  46. '''Context for testing an image'''
  47. def __init__(self, appimage):
  48. self.appimage = appimage
  49. # Guess python version from appimage name.
  50. version, _, abi, *_ = appimage.name.split('-', 3)
  51. version = version[6:]
  52. if abi.endswith('t'):
  53. version += '-nogil'
  54. self.version = PythonVersion.from_str(version)
  55. # Get some specific AppImage env variables.
  56. self.env = eval(Script('''
  57. import os
  58. appdir = os.environ['APPDIR']
  59. env = {}
  60. for var in ('SSL_CERT_FILE', 'TCL_LIBRARY', 'TK_LIBRARY', 'TKPATH'):
  61. try:
  62. env[var] = os.environ[var].replace(appdir, '$APPDIR')
  63. except KeyError:
  64. pass
  65. print(env)
  66. ''').run(appimage))
  67. # Extract the AppImage.
  68. tmpdir = tempfile.TemporaryDirectory()
  69. dst = Path(tmpdir.name) / appimage.name
  70. shutil.copy(appimage, dst)
  71. system(f'cd {tmpdir.name} && ./{appimage.name} --appimage-extract')
  72. self.appdir = Path(tmpdir.name) / 'squashfs-root'
  73. self.tmpdir = tmpdir
  74. def list_content(self, path=None):
  75. '''List the content of an extracted directory'''
  76. path = self.appdir if path is None else self.appdir / path
  77. return sorted(os.listdir(path))
  78. def run(self):
  79. '''Run all tests'''
  80. tests = []
  81. for key, value in self.__class__.__dict__.items():
  82. if isinstance(value, FunctionType):
  83. if key.startswith('test_'):
  84. tests.append(value)
  85. n = len(tests)
  86. m = max(len(test.__doc__) for test in tests)
  87. for i, test in enumerate(tests):
  88. sys.stdout.write(
  89. f'[ {self.appimage.name} | {i + 1:2}/{n} ] {test.__doc__:{m}}'
  90. )
  91. sys.stdout.flush()
  92. try:
  93. status = test(self)
  94. except Exception as e:
  95. status = Status.FAILED
  96. sys.stdout.write(
  97. f' -> {status} ({test.__name__}){os.linesep}')
  98. sys.stdout.flush()
  99. raise e
  100. else:
  101. sys.stdout.write(f' -> {status}{os.linesep}')
  102. sys.stdout.flush()
  103. def test_root_content(self):
  104. '''Check the appimage root content'''
  105. content = self.list_content()
  106. expected = ['.DirIcon', 'AppRun', 'opt', 'python.png',
  107. f'python{self.version.long()}.desktop', 'usr']
  108. assert_eq(expected, content)
  109. return Status.SUCCESS
  110. def test_python_content(self):
  111. '''Check the appimage python content'''
  112. prefix = f'opt/python{self.version.flavoured()}'
  113. content = self.list_content(prefix)
  114. assert_eq(['bin', 'include', 'lib'], content)
  115. content = self.list_content(f'{prefix}/bin')
  116. assert_eq(
  117. [f'pip{self.version.short()}', f'python{self.version.flavoured()}'],
  118. content
  119. )
  120. content = self.list_content(f'{prefix}/include')
  121. if (self.version.major == 3) and (self.version.minor <= 7):
  122. expected = [f'python{self.version.short()}m']
  123. else:
  124. expected = [f'python{self.version.flavoured()}']
  125. assert_eq(expected, content)
  126. content = self.list_content(f'{prefix}/lib')
  127. assert_eq([f'python{self.version.flavoured()}'], content)
  128. return Status.SUCCESS
  129. def test_system_content(self):
  130. '''Check the appimage system content'''
  131. content = self.list_content('usr')
  132. assert_eq(['bin', 'lib', 'share'], content)
  133. content = self.list_content('usr/bin')
  134. expected = [
  135. 'pip', f'pip{self.version.major}', f'pip{self.version.short()}',
  136. 'python', f'python{self.version.major}',
  137. f'python{self.version.short()}'
  138. ]
  139. assert_eq(expected, content)
  140. return Status.SUCCESS
  141. def test_tcltk_bundling(self):
  142. '''Check Tcl/Tk bundling'''
  143. if 'TK_LIBRARY' not in self.env:
  144. return Status.SKIPPED
  145. else:
  146. for var in ('TCL_LIBRARY', 'TK_LIBRARY', 'TKPATH'):
  147. path = Path(self.env[var].replace('$APPDIR', str(self.appdir)))
  148. assert path.exists()
  149. return Status.SUCCESS
  150. def test_ssl_bundling(self):
  151. '''Check SSL certs bundling'''
  152. var = 'SSL_CERT_FILE'
  153. path = Path(self.env[var].replace('$APPDIR', str(self.appdir)))
  154. assert path.exists()
  155. return Status.SUCCESS
  156. def test_bin_symlinks(self):
  157. '''Check /usr/bin symlinks'''
  158. assert_eq(
  159. (self.appdir /
  160. f'opt/python{self.version.flavoured()}/bin/pip{self.version.short()}'),
  161. (self.appdir / f'usr/bin/pip{self.version.short()}').resolve()
  162. )
  163. assert_eq(
  164. f'pip{self.version.short()}',
  165. str((self.appdir / f'usr/bin/pip{self.version.major}').readlink())
  166. )
  167. assert_eq(
  168. f'pip{self.version.major}',
  169. str((self.appdir / 'usr/bin/pip').readlink())
  170. )
  171. assert_eq(
  172. f'python{self.version.short()}',
  173. str((self.appdir / f'usr/bin/python{self.version.major}').readlink())
  174. )
  175. assert_eq(
  176. f'python{self.version.major}',
  177. str((self.appdir / 'usr/bin/python').readlink())
  178. )
  179. return Status.SUCCESS
  180. def test_appimage_hook(self):
  181. '''Test the appimage hook'''
  182. Script(f'''
  183. import os
  184. assert_eq(os.environ['APPIMAGE_COMMAND'], '{self.appimage}')
  185. import sys
  186. assert_eq('{self.appimage}', sys.executable)
  187. assert_eq('{self.appimage}', sys._base_executable)
  188. ''').run(self.appimage)
  189. return Status.SUCCESS
  190. def test_python_prefix(self):
  191. '''Test the python prefix'''
  192. Script(f'''
  193. import os
  194. import sys
  195. expected = os.environ["APPDIR"] + '/opt/python{self.version.flavoured()}'
  196. assert_eq(expected, sys.prefix)
  197. ''').run(self.appimage)
  198. return Status.SUCCESS
  199. def test_ssl_request(self):
  200. '''Test SSL request (see issue #24)'''
  201. if self.version.major == 2:
  202. return Status.SKIPPED
  203. else:
  204. Script('''
  205. from http import HTTPStatus
  206. import urllib.request
  207. with urllib.request.urlopen('https://wikipedia.org') as r:
  208. assert_eq(r.status, HTTPStatus.OK)
  209. ''').run(self.appimage)
  210. return Status.SUCCESS
  211. def test_pip_install(self):
  212. '''Test pip installing to an extracted AppImage'''
  213. r = system(f'{self.appdir}/AppRun -m pip install pip-install-test')
  214. assert('Successfully installed pip-install-test' in r)
  215. path = self.appdir / f'opt/python{self.version.flavoured()}/lib/python{self.version.flavoured()}/site-packages/pip_install_test'
  216. assert(path.exists())
  217. return Status.SUCCESS
  218. def test_tkinter_usage(self):
  219. '''Test basic tkinter usage'''
  220. try:
  221. os.environ['DISPLAY']
  222. self.env['TK_LIBRARY']
  223. except KeyError:
  224. return Status.SKIPPED
  225. else:
  226. tkinter = 'tkinter' if self.version.major > 2 else 'Tkinter'
  227. Script(f'''
  228. import {tkinter} as tkinter
  229. tkinter.Tk()
  230. ''').run(self.appimage)
  231. return Status.SUCCESS
  232. def test_venv_usage(self):
  233. '''Test venv creation'''
  234. if self.version.major == 2:
  235. return Status.SKIPPED
  236. else:
  237. system(' && '.join((
  238. f'cd {self.tmpdir.name}',
  239. f'./{self.appimage.name} -m venv ENV',
  240. '. ENV/bin/activate',
  241. )))
  242. python = Path(f'{self.tmpdir.name}/ENV/bin/python')
  243. assert_eq(self.appimage.name, str(python.readlink()))
  244. return Status.SUCCESS
  245. def test():
  246. '''Test Python AppImage(s)'''
  247. for appimage in ARGS.appimage:
  248. context = TestContext(appimage)
  249. context.run()
  250. if __name__ == '__main__':
  251. parser = argparse.ArgumentParser(description = test.__doc__)
  252. parser.add_argument('appimage',
  253. help = 'path to appimage(s)',
  254. nargs = '+',
  255. type = lambda x: Path(x).absolute()
  256. )
  257. ARGS = parser.parse_args()
  258. test()