test-appimage.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  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. env[var] = os.environ[var].replace(appdir, '$APPDIR')
  62. print(env)
  63. ''').run(appimage))
  64. # Extract the AppImage.
  65. tmpdir = tempfile.TemporaryDirectory()
  66. dst = Path(tmpdir.name) / appimage.name
  67. shutil.copy(appimage, dst)
  68. system(f'cd {tmpdir.name} && ./{appimage.name} --appimage-extract')
  69. self.appdir = Path(tmpdir.name) / 'squashfs-root'
  70. self.tmpdir = tmpdir
  71. def list_content(self, path=None):
  72. '''List the content of an extracted directory'''
  73. path = self.appdir if path is None else self.appdir / path
  74. return sorted(os.listdir(path))
  75. def run(self):
  76. '''Run all tests'''
  77. tests = []
  78. for key, value in self.__class__.__dict__.items():
  79. if isinstance(value, FunctionType):
  80. if key.startswith('test_'):
  81. tests.append(value)
  82. n = len(tests)
  83. m = max(len(test.__doc__) for test in tests)
  84. for i, test in enumerate(tests):
  85. sys.stdout.write(
  86. f'[ {self.appimage.name} | {i + 1:2}/{n} ] {test.__doc__:{m}}'
  87. )
  88. sys.stdout.flush()
  89. try:
  90. status = test(self)
  91. except Exception as e:
  92. status = Status.FAILED
  93. sys.stdout.write(
  94. f' -> {status} ({test.__name__}){os.linesep}')
  95. sys.stdout.flush()
  96. raise e
  97. else:
  98. sys.stdout.write(f' -> {status}{os.linesep}')
  99. sys.stdout.flush()
  100. def test_root_content(self):
  101. '''Check the appimage root content'''
  102. content = self.list_content()
  103. expected = ['.DirIcon', 'AppRun', 'opt', 'python.png',
  104. f'python{self.version.long()}.desktop', 'usr']
  105. assert_eq(expected, content)
  106. return Status.SUCCESS
  107. def test_python_content(self):
  108. '''Check the appimage python content'''
  109. prefix = f'opt/python{self.version.flavoured()}'
  110. content = self.list_content(prefix)
  111. assert_eq(['bin', 'include', 'lib'], content)
  112. content = self.list_content(f'{prefix}/bin')
  113. assert_eq(
  114. [f'pip{self.version.short()}', f'python{self.version.flavoured()}'],
  115. content
  116. )
  117. content = self.list_content(f'{prefix}/include')
  118. assert_eq([f'python{self.version.flavoured()}'], content)
  119. content = self.list_content(f'{prefix}/lib')
  120. assert_eq([f'python{self.version.flavoured()}'], content)
  121. return Status.SUCCESS
  122. def test_system_content(self):
  123. '''Check the appimage system content'''
  124. content = self.list_content('usr')
  125. assert_eq(['bin', 'lib', 'share'], content)
  126. content = self.list_content('usr/bin')
  127. expected = [
  128. 'pip', f'pip{self.version.major}', f'pip{self.version.short()}',
  129. 'python', f'python{self.version.major}',
  130. f'python{self.version.short()}'
  131. ]
  132. assert_eq(expected, content)
  133. return Status.SUCCESS
  134. def test_tcltk_bundling(self):
  135. '''Check Tcl/Tk bundling'''
  136. for var in ('TCL_LIBRARY', 'TK_LIBRARY', 'TKPATH'):
  137. path = Path(self.env[var].replace('$APPDIR', str(self.appdir)))
  138. assert path.exists()
  139. return Status.SUCCESS
  140. def test_ssl_bundling(self):
  141. '''Check SSL certs bundling'''
  142. var = 'SSL_CERT_FILE'
  143. path = Path(self.env[var].replace('$APPDIR', str(self.appdir)))
  144. assert path.exists()
  145. return Status.SUCCESS
  146. def test_bin_symlinks(self):
  147. '''Check /usr/bin symlinks'''
  148. assert_eq(
  149. (self.appdir /
  150. f'opt/python{self.version.flavoured()}/bin/pip{self.version.short()}'),
  151. (self.appdir / f'usr/bin/pip{self.version.short()}').resolve()
  152. )
  153. assert_eq(
  154. f'pip{self.version.short()}',
  155. str((self.appdir / f'usr/bin/pip{self.version.major}').readlink())
  156. )
  157. assert_eq(
  158. f'pip{self.version.major}',
  159. str((self.appdir / 'usr/bin/pip').readlink())
  160. )
  161. assert_eq(
  162. f'python{self.version.short()}',
  163. str((self.appdir / f'usr/bin/python{self.version.major}').readlink())
  164. )
  165. assert_eq(
  166. f'python{self.version.major}',
  167. str((self.appdir / 'usr/bin/python').readlink())
  168. )
  169. return Status.SUCCESS
  170. def test_appimage_hook(self):
  171. '''Test the appimage hook'''
  172. Script(f'''
  173. import os
  174. assert_eq(os.environ['APPIMAGE_COMMAND'], '{self.appimage}')
  175. import sys
  176. assert_eq('{self.appimage}', sys.executable)
  177. assert_eq('{self.appimage}', sys._base_executable)
  178. ''').run(self.appimage)
  179. return Status.SUCCESS
  180. def test_python_prefix(self):
  181. '''Test the python prefix'''
  182. Script(f'''
  183. import os
  184. import sys
  185. expected = os.environ["APPDIR"] + '/opt/python{self.version.flavoured()}'
  186. assert_eq(expected, sys.prefix)
  187. ''').run(self.appimage)
  188. return Status.SUCCESS
  189. def test_ssl_request(self):
  190. '''Test SSL request (see issue #24)'''
  191. if self.version.major == 2:
  192. return Status.SKIPPED
  193. else:
  194. Script('''
  195. from http import HTTPStatus
  196. import urllib.request
  197. with urllib.request.urlopen('https://wikipedia.org') as r:
  198. assert_eq(r.status, HTTPStatus.OK)
  199. ''').run(self.appimage)
  200. return Status.SUCCESS
  201. def test_pip_install(self):
  202. '''Test pip installing to an extracted AppImage'''
  203. r = system(f'{self.appdir}/AppRun -m pip install pip-install-test')
  204. assert('Successfully installed pip-install-test' in r)
  205. path = self.appdir / f'opt/python{self.version.flavoured()}/lib/python{self.version.flavoured()}/site-packages/pip_install_test'
  206. assert(path.exists())
  207. return Status.SUCCESS
  208. def test_tkinter_usage(self):
  209. '''Test basic tkinter usage'''
  210. try:
  211. os.environ['DISPLAY']
  212. except KeyError:
  213. return Status.SKIPPED
  214. else:
  215. tkinter = 'tkinter' if self.version.major > 2 else 'Tkinter'
  216. Script(f'''
  217. import {tkinter} as tkinter
  218. tkinter.Tk()
  219. ''').run(self.appimage)
  220. return Status.SUCCESS
  221. def test_venv_usage(self):
  222. '''Test venv creation'''
  223. if self.version.major == 2:
  224. return Status.SKIPPED
  225. else:
  226. system(' && '.join((
  227. f'cd {self.tmpdir.name}',
  228. f'./{self.appimage.name} -m venv ENV',
  229. '. ENV/bin/activate',
  230. )))
  231. python = Path(f'{self.tmpdir.name}/ENV/bin/python')
  232. assert_eq(self.appimage.name, str(python.readlink()))
  233. return Status.SUCCESS
  234. def test():
  235. '''Test Python AppImage(s)'''
  236. for appimage in ARGS.appimage:
  237. context = TestContext(appimage)
  238. context.run()
  239. if __name__ == '__main__':
  240. parser = argparse.ArgumentParser(description = test.__doc__)
  241. parser.add_argument('appimage',
  242. help = 'path to appimage(s)',
  243. nargs = '+',
  244. type = lambda x: Path(x).absolute()
  245. )
  246. ARGS = parser.parse_args()
  247. test()