swc-installation-test-2.py: Print full DependencyError chain
[swc-setup-installation-test.git] / setup / swc-installation-test-2.py
1 #!/usr/bin/env python
2
3 """Test script to check for required functionality.
4
5 Execute this code at the command line by typing:
6
7   python swc-installation-test-2.py
8
9 Run the script and follow the instructions it prints at the end.
10
11 This script requires at least Python 2.6.  You can check the version
12 of Python that you have installed with 'swc-installation-test-1.py'.
13
14 By default, this script will test for all the dependencies your
15 instructor thinks you need.  If you want to test for a different set
16 of packages, you can list them on the command line.  For example:
17
18   python swc-installation-test-2.py git virtual-editor
19
20 This is useful if the original test told you to install a more recent
21 version of a particular dependency, and you just want to re-test that
22 dependency.
23 """
24
25 from __future__ import print_function  # for Python 2.6 compatibility
26
27 import distutils.ccompiler as _distutils_ccompiler
28 try:  # Python 2.7 and 3.x
29     import importlib as _importlib
30 except ImportError:  # Python 2.6 and earlier
31     class _Importlib (object):
32         """Minimal workarounds for functions we need
33         """
34         @staticmethod
35         def import_module(name):
36             module = __import__(name)
37             for n in name.split('.')[1:]:
38                 module = getattr(module, n)
39             return module
40     _importlib = _Importlib()
41 import logging as _logging
42 import os as _os
43 import platform as _platform
44 import re as _re
45 import shlex as _shlex
46 import subprocess as _subprocess
47 import sys as _sys
48
49
50 if not hasattr(_shlex, 'quote'):  # Python versions older than 3.3
51     # Use the undocumented pipes.quote()
52     import pipes as _pipes
53     _shlex.quote = _pipes.quote
54
55
56 __version__ = '0.1'
57
58 # Comment out any entries you don't need
59 CHECKS = [
60 # Shell
61     'virtual-shell',
62 # Editors
63     'virtual-editor',
64 # Browsers
65     'virtual-browser',
66 # Version control
67     'git',
68     'hg',              # Command line tool
69     #'mercurial',       # Python package
70 # Build tools and packaging
71     'make',
72     'virtual-pypi-installer',
73     'setuptools',
74 # Testing
75     'nosetests',       # Command line tool
76     'nose',            # Python package
77 # SQL
78     'sqlite3',         # Command line tool
79     'sqlite3-python',  # Python package
80 # Python
81     'python',
82     'ipython',         # Command line tool
83     'IPython',         # Python package
84     'numpy',
85     'scipy',
86     'matplotlib',
87     'pandas',
88     'sympy',
89     'Cython',
90     'networkx',
91     'mayavi.mlab',
92     ]
93
94 CHECKER = {}
95
96
97 class InvalidCheck (KeyError):
98     def __init__(self, check):
99         super(InvalidCheck, self).__init__(check)
100         self.check = check
101
102     def __str__(self):
103         return self.check
104
105
106 class DependencyError (Exception):
107     def _get_message(self):
108         return self._message
109     def _set_message(self, message):
110         self._message = message
111     message = property(_get_message, _set_message)
112
113     def __init__(self, checker, message, cause=None):
114         super(DependencyError, self).__init__(message)
115         self.checker = checker
116         self.message = message
117         self.cause = cause
118
119     def __str__(self):
120         url = 'http://software-carpentry.org/setup/'  # TODO: per-package URL
121         lines = [
122             'check for {0} failed:'.format(self.checker.full_name()),
123             '  ' + self.message,
124             '  For instructions on installing an up-to-date version, see',
125             '  ' + url,
126             ]
127         if self.cause:
128             lines.append('  cause:')
129             lines.extend('  ' + line for line in str(self.cause).splitlines())
130         return '\n'.join(lines)
131
132
133 def check(checks=None):
134     successes = []
135     failures = []
136     if not checks:
137         checks = CHECKS
138     for check in checks:
139         try:
140             checker = CHECKER[check]
141         except KeyError as e:
142             raise InvalidCheck(check)# from e
143         _sys.stdout.write('check {0}...\t'.format(checker.full_name()))
144         try:
145             version = checker.check()
146         except DependencyError as e:
147             failures.append(e)
148             _sys.stdout.write('fail\n')
149         else:
150             _sys.stdout.write('pass\n')
151             successes.append((checker, version))
152     if successes:
153         print('\nSuccesses:\n')
154         for checker,version in successes:
155             print('{0} {1}'.format(
156                     checker.full_name(),
157                     version or 'unknown'))
158     if failures:
159         print('\nFailures:')
160         printed = []
161         for failure in failures:
162             if failure not in printed:
163                 print()
164                 print(failure)
165                 printed.append(failure)
166         return False
167     return True
168
169
170 class Dependency (object):
171     def __init__(self, name, long_name=None, minimum_version=None,
172                  version_delimiter='.', and_dependencies=None,
173                  or_dependencies=None):
174         self.name = name
175         self.long_name = long_name or name
176         self.minimum_version = minimum_version
177         self.version_delimiter = version_delimiter
178         if not and_dependencies:
179             and_dependencies = []
180         self.and_dependencies = and_dependencies
181         if not or_dependencies:
182             or_dependencies = []
183         self.or_dependencies = or_dependencies
184         self._check_error = None
185
186     def __str__(self):
187         return '<{0} {1}>'.format(type(self).__name__, self.name)
188
189     def full_name(self):
190         if self.name == self.long_name:
191             return self.name
192         else:
193             return '{0} ({1})'.format(self.long_name, self.name)
194
195     def check(self):
196         if self._check_error:
197             raise self._check_error
198         try:
199             self._check_dependencies()
200             return self._check()
201         except DependencyError as e:
202             self._check_error = e  # cache for future calls
203             raise
204
205     def _check_dependencies(self):
206         for dependency in self.and_dependencies:
207             if not hasattr(dependency, 'check'):
208                 dependency = CHECKER[dependency]
209             try:
210                 dependency.check()
211             except DependencyError as e:
212                 raise DependencyError(
213                     checker=self,
214                     message='and-dependency not satisfied for {0}'.format(
215                         self.full_name()),
216                     cause=e)
217         self.or_pass = or_error = None
218         for dependency in self.or_dependencies:
219             if not hasattr(dependency, 'check'):
220                 dependency = CHECKER[dependency]
221             try:
222                 version = dependency.check()
223             except DependencyError as e:
224                 or_error = e
225             else:
226                 self.or_pass = {
227                     'dependency': dependency,
228                     'version': version,
229                     }
230                 break  # no need to test other dependencies
231         if self.or_dependencies and not self.or_pass:
232             raise DependencyError(
233                 checker=self,
234                 message='or-dependency not satisfied for {0}'.format(
235                     self.full_name()),
236                     cause=or_error)
237
238     def _check(self):
239         version = self._get_version()
240         parsed_version = None
241         if hasattr(self, '_get_parsed_version'):
242             parsed_version = self._get_parsed_version()
243         if self.minimum_version:
244             self._check_version(version=version, parsed_version=parsed_version)
245         return version
246
247     def _get_version(self):
248         raise NotImplementedError(self)
249
250     def _minimum_version_string(self):
251         return self.version_delimiter.join(
252             str(part) for part in self.minimum_version)
253
254     def _check_version(self, version, parsed_version=None):
255         if not parsed_version:
256             parsed_version = self._parse_version(version=version)
257         if not parsed_version or parsed_version < self.minimum_version:
258             raise DependencyError(
259                 checker=self,
260                 message='outdated version of {0}: {1} (need >= {2})'.format(
261                     self.full_name(), version, self._minimum_version_string()))
262
263     def _parse_version(self, version):
264         if not version:
265             return None
266         parsed_version = []
267         for part in version.split(self.version_delimiter):
268             try:
269                 parsed_version.append(int(part))
270             except ValueError as e:
271                 raise DependencyError(
272                     checker=self,
273                     message=(
274                         'unparsable {0!r} in version {1} of {2}, (need >= {3})'
275                         ).format(
276                         part, version, self.full_name(),
277                         self._minimum_version_string()))# from e
278         return tuple(parsed_version)
279
280
281 class PythonDependency (Dependency):
282     def __init__(self, name='python', long_name='Python version',
283                  minimum_version=(2, 6), **kwargs):
284         super(PythonDependency, self).__init__(
285             name=name, long_name=long_name, minimum_version=minimum_version,
286             **kwargs)
287
288     def _get_version(self):
289         return _sys.version
290
291     def _get_parsed_version(self):
292         return _sys.version_info
293
294
295 CHECKER['python'] = PythonDependency()
296
297
298 class CommandDependency (Dependency):
299     exe_extension = _distutils_ccompiler.new_compiler().exe_extension
300
301     def __init__(self, command, version_options=('--version',), stdin=None,
302                  version_regexp=None, version_stream='stdout', **kwargs):
303         if 'name' not in kwargs:
304             kwargs['name'] = command
305         super(CommandDependency, self).__init__(**kwargs)
306         self.command = command
307         self.version_options = version_options
308         self.stdin = None
309         if not version_regexp:
310             regexp = r'([\d][\d{0}]*[\d])'.format(self.version_delimiter)
311             version_regexp = _re.compile(regexp)
312         self.version_regexp = version_regexp
313         self.version_stream = version_stream
314
315     def _get_version_stream(self, stdin=None, expect=(0,)):
316         if not stdin:
317             stdin = self.stdin
318         if stdin:
319             popen_stdin = _subprocess.PIPE
320         else:
321             popen_stdin = None
322         command = self.command + (self.exe_extension or '')
323         try:
324             p = _subprocess.Popen(
325                 [command] + list(self.version_options), stdin=popen_stdin,
326                 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE,
327                 close_fds=True, shell=False, universal_newlines=True)
328         except OSError as e:
329             raise DependencyError(
330                 checker=self,
331                 message="could not find '{0}' executable".format(command),
332                 )# from e
333         stdout,stderr = p.communicate(stdin)
334         status = p.wait()
335         if status not in expect:
336             lines = [
337                 "failed to execute: {0} {1}".format(
338                     command,
339                     ' '.join(_shlex.quote(arg)
340                              for arg in self.version_options)),
341                 'status: {0}'.format(status),
342                 ]
343             for name,string in [('stdout', stdout), ('stderr', stderr)]:
344                 if string:
345                     lines.extend([name + ':', string])
346             raise DependencyError(checker=self, message='\n'.join(lines))
347         for name,string in [('stdout', stdout), ('stderr', stderr)]:
348             if name == self.version_stream:
349                 return string
350         raise NotImplementedError(self.version_stream)
351
352     def _get_version(self):
353         version_stream = self._get_version_stream()
354         match = self.version_regexp.search(version_stream)
355         if not match:
356             raise DependencyError(
357                 checker=self,
358                 message='no version string in output:\n{0}'.format(
359                     version_stream))
360         return match.group(1)
361
362
363 for command,long_name,minimum_version in [
364         ('sh', 'Bourne Shell', None),
365         ('ash', 'Almquist Shell', None),
366         ('bash', 'Bourne Again Shell', None),
367         ('csh', 'C Shell', None),
368         ('ksh', 'KornShell', None),
369         ('dash', 'Debian Almquist Shell', None),
370         ('tcsh', 'TENEX C Shell', None),
371         ('zsh', 'Z Shell', None),
372         ('git', 'Git', (1, 7, 0)),
373         ('hg', 'Mercurial', (2, 0, 0)),
374         ('pip', None, None),
375         ('sqlite3', 'SQLite 3', None),
376         ('nosetests', 'Nose', (1, 0, 0)),
377         ('ipython', 'IPython script', (0, 13)),
378         ('emacs', 'Emacs', None),
379         ('xemacs', 'XEmacs', None),
380         ('vim', 'Vim', None),
381         ('vi', None, None),
382         ('nano', 'Nano', None),
383         ('gedit', None, None),
384         ('kate', 'Kate', None),
385         ('notepad++', 'Notepad++', None),
386         ('firefox', 'Firefox', None),
387         ('google-chrome', 'Google Chrome', None),
388         ('chromium', 'Chromium', None),
389         ]:
390     if not long_name:
391         long_name = command
392     CHECKER[command] = CommandDependency(
393         command=command, long_name=long_name, minimum_version=minimum_version)
394 del command, long_name, minimum_version  # cleanup namespace
395
396
397 class MakeDependency (CommandDependency):
398     makefile = '\n'.join([
399             'all:',
400             '\t@echo "MAKE_VERSION=$(MAKE_VERSION)"',
401             '\t@echo "MAKE=$(MAKE)"',
402             '',
403             ])
404
405     def _get_version(self):
406         try:
407             return super(MakeDependency, self)._get_version()
408         except DependencyError as e:
409             version_options = self.version_options
410             self.version_options = ['-f', '-']
411             try:
412                 stream = self._get_version_stream(stdin=self.makefile)
413                 info = {}
414                 for line in stream.splitlines():
415                     try:
416                         key,value = line.split('=', 1)
417                     except ValueError as ve:
418                         raise e# from NotImplementedError(stream)
419                     info[key] = value
420                 if info.get('MAKE_VERSION', None):
421                     return info['MAKE_VERSION']
422                 elif info.get('MAKE', None):
423                     return None
424                 raise e
425             finally:
426                 self.version_options = version_options
427
428
429 CHECKER['make'] = MakeDependency(command='make', minimum_version=None)
430
431
432 class EasyInstallDependency (CommandDependency):
433     def _get_version(self):
434         try:
435             return super(EasyInstallDependency, self)._get_version()
436         except DependencyError as e:
437             version_stream = self.version_stream
438             try:
439                 self.version_stream = 'stderr'
440                 stream = self._get_version_stream(expect=(1,))
441                 if 'option --version not recognized' in stream:
442                     return 'unknown (possibly Setuptools?)'
443             finally:
444                 self.version_stream = version_stream
445
446
447 CHECKER['easy_install'] = EasyInstallDependency(
448     command='easy_install', long_name='Setuptools easy_install',
449     minimum_version=None)
450
451
452 class PythonPackageDependency (Dependency):
453     def __init__(self, package, **kwargs):
454         if 'name' not in kwargs:
455             kwargs['name'] = package
456         if 'and_dependencies' not in kwargs:
457             kwargs['and_dependencies'] = []
458         if 'python' not in kwargs['and_dependencies']:
459             kwargs['and_dependencies'].append('python')
460         super(PythonPackageDependency, self).__init__(**kwargs)
461         self.package = package
462
463     def _get_version(self):
464         package = self._get_package(self.package)
465         return self._get_version_from_package(package)
466
467     def _get_package(self, package):
468         try:
469             return _importlib.import_module(package)
470         except ImportError as e:
471             raise DependencyError(
472                 checker=self,
473                 message="could not import the '{0}' package for {1}".format(
474                     package, self.full_name()),
475                 )# from e
476
477     def _get_version_from_package(self, package):
478         try:
479             version = package.__version__
480         except AttributeError:
481             version = None
482         return version
483
484
485 for package,name,long_name,minimum_version,and_dependencies in [
486         ('nose', None, 'Nose Python package',
487          CHECKER['nosetests'].minimum_version, None),
488         ('IPython', None, 'IPython Python package',
489          CHECKER['ipython'].minimum_version, None),
490         ('numpy', None, 'NumPy', None, None),
491         ('scipy', None, 'SciPy', None, None),
492         ('matplotlib', None, 'Matplotlib', None, None),
493         ('pandas', None, 'Pandas', (0, 8), None),
494         ('sympy', None, 'SymPy', None, None),
495         ('Cython', None, None, None, None),
496         ('networkx', None, 'NetworkX', None, None),
497         ('mayavi.mlab', None, 'MayaVi', None, None),
498         ('setuptools', None, 'Setuptools', None, None),
499         ]:
500     if not name:
501         name = package
502     if not long_name:
503         long_name = name
504     kwargs = {}
505     if and_dependencies:
506         kwargs['and_dependencies'] = and_dependencies
507     CHECKER[name] = PythonPackageDependency(
508         package=package, name=name, long_name=long_name,
509         minimum_version=minimum_version, **kwargs)
510 # cleanup namespace
511 del package, name, long_name, minimum_version, and_dependencies, kwargs
512
513
514 class MercurialPythonPackage (PythonPackageDependency):
515     def _get_version(self):
516         try:  # mercurial >= 1.2
517             package = _importlib.import_module('mercurial.util')
518         except ImportError as e:  # mercurial <= 1.1.2
519             package = self._get_package('mercurial.version')
520             return package.get_version()
521         else:
522             return package.version()
523
524
525 CHECKER['mercurial'] = MercurialPythonPackage(
526     package='mercurial.util', name='mercurial',
527     long_name='Mercurial Python package',
528     minimum_version=CHECKER['hg'].minimum_version)
529
530
531 class SQLitePythonPackage (PythonPackageDependency):
532     def _get_version_from_package(self, package):
533         return _sys.version
534
535     def _get_parsed_version(self):
536         return _sys.version_info
537
538
539 CHECKER['sqlite3-python'] = SQLitePythonPackage(
540     package='sqlite3', name='sqlite3-python',
541     long_name='SQLite Python package',
542     minimum_version=CHECKER['sqlite3'].minimum_version)
543
544
545 class VirtualDependency (Dependency):
546     def _check(self):
547         return '{0} {1}'.format(
548             self.or_pass['dependency'].full_name(),
549             self.or_pass['version'])
550
551
552 for name,dependencies in [
553         ('virtual-shell', (
554             'bash',
555             'dash',
556             'ash',
557             'zsh',
558             'ksh',
559             'csh',
560             'tcsh',
561             'sh',
562             )),
563         ('virtual-editor', (
564             'emacs',
565             'xemacs',
566             'vim',
567             'vi',
568             'nano',
569             'gedit',
570             'kate',
571             'notepad++',
572             )),
573         ('virtual-browser', (
574             'firefox',
575             'google-chrome',
576             'chromium',
577             )),
578         ('virtual-pypi-installer', (
579             'easy_install',
580             'pip',
581             )),
582         ]:
583     CHECKER[name] = VirtualDependency(
584         name=name, long_name=name, or_dependencies=dependencies)
585 del name, dependencies  # cleanup namespace
586
587
588 def _print_info(key, value, indent=19):
589     print('{0}{1}: {2}'.format(key, ' '*(indent-len(key)), value))
590
591 def print_system_info():
592     print("If you do not understand why the above failures occurred,")
593     print("copy and send the *entire* output (all info above and summary")
594     print("below) to the instructor for help.")
595     print()
596     print('==================')
597     print('System information')
598     print('==================')
599     _print_info('os.name', _os.name)
600     _print_info('os.uname', _platform.uname())
601     _print_info('platform', _sys.platform)
602     _print_info('platform+', _platform.platform())
603     for pversion in (
604             'linux_distribution',
605             'mac_ver',
606             'win32_ver',
607             ):
608         value = getattr(_platform, pversion)()
609         if value[0]:
610             _print_info(pversion, value)
611     _print_info('prefix', _sys.prefix)
612     _print_info('exec_prefix', _sys.exec_prefix)
613     _print_info('executable', _sys.executable)
614     _print_info('version_info', _sys.version_info)
615     _print_info('version', _sys.version)
616     _print_info('environment', '')
617     for key,value in sorted(_os.environ.items()):
618         print('  {0}={1}'.format(key, value))
619     print('==================')
620
621 def print_suggestions(instructor_fallback=True):
622     print()
623     print('For suggestions on installing missing packages, see')
624     print('http://software-carpentry.org/setup/')
625     print('')
626     print('For instructings on installing a particular package,')
627     print('see the failure message for that package printed above.')
628     if instructor_fallback:
629         print('')
630         print('For help, email the *entire* output of this script to')
631         print('your instructor.')
632
633
634 if __name__ == '__main__':
635     try:
636         passed = check(_sys.argv[1:])
637     except InvalidCheck as e:
638         print("I don't know how to check for {0!r}".format(e.check))
639         print('I do know how to check for:')
640         for key,checker in sorted(CHECKER.items()):
641             if checker.long_name != checker.name:
642                 print('  {0} {1}({2})'.format(
643                         key, ' '*(20-len(key)), checker.long_name))
644             else:
645                 print('  {0}'.format(key))
646         _sys.exit(1)
647     if not passed:
648         print()
649         print_system_info()
650         print_suggestions(instructor_fallback=True)
651         _sys.exit(1)