swc-installation-test-2.py: Add PathCommandDependency
[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     #'xcode',
75 # Testing
76     'nosetests',       # Command line tool
77     'nose',            # Python package
78 # SQL
79     'sqlite3',         # Command line tool
80     'sqlite3-python',  # Python package
81 # Python
82     'python',
83     'ipython',         # Command line tool
84     'IPython',         # Python package
85     'numpy',
86     'scipy',
87     'matplotlib',
88     'pandas',
89     'sympy',
90     'Cython',
91     'networkx',
92     'mayavi.mlab',
93     ]
94
95 CHECKER = {}
96
97
98 class InvalidCheck (KeyError):
99     def __init__(self, check):
100         super(InvalidCheck, self).__init__(check)
101         self.check = check
102
103     def __str__(self):
104         return self.check
105
106
107 class DependencyError (Exception):
108     def _get_message(self):
109         return self._message
110     def _set_message(self, message):
111         self._message = message
112     message = property(_get_message, _set_message)
113
114     def __init__(self, checker, message, causes=None):
115         super(DependencyError, self).__init__(message)
116         self.checker = checker
117         self.message = message
118         if causes is None:
119             causes = []
120         self.causes = causes
121
122     def __str__(self):
123         url = 'http://software-carpentry.org/setup/'  # TODO: per-package URL
124         lines = [
125             'check for {0} failed:'.format(self.checker.full_name()),
126             '  ' + self.message,
127             '  For instructions on installing an up-to-date version, see',
128             '  ' + url,
129             ]
130         if self.causes:
131             lines.append('  causes:')
132             for cause in self.causes:
133                 lines.extend('  ' + line for line in str(cause).splitlines())
134         return '\n'.join(lines)
135
136
137 def check(checks=None):
138     successes = []
139     failures = []
140     if not checks:
141         checks = CHECKS
142     for check in checks:
143         try:
144             checker = CHECKER[check]
145         except KeyError as e:
146             raise InvalidCheck(check)# from e
147         _sys.stdout.write('check {0}...\t'.format(checker.full_name()))
148         try:
149             version = checker.check()
150         except DependencyError as e:
151             failures.append(e)
152             _sys.stdout.write('fail\n')
153         else:
154             _sys.stdout.write('pass\n')
155             successes.append((checker, version))
156     if successes:
157         print('\nSuccesses:\n')
158         for checker,version in successes:
159             print('{0} {1}'.format(
160                     checker.full_name(),
161                     version or 'unknown'))
162     if failures:
163         print('\nFailures:')
164         printed = []
165         for failure in failures:
166             if failure not in printed:
167                 print()
168                 print(failure)
169                 printed.append(failure)
170         return False
171     return True
172
173
174 class Dependency (object):
175     def __init__(self, name, long_name=None, minimum_version=None,
176                  version_delimiter='.', and_dependencies=None,
177                  or_dependencies=None):
178         self.name = name
179         self.long_name = long_name or name
180         self.minimum_version = minimum_version
181         self.version_delimiter = version_delimiter
182         if not and_dependencies:
183             and_dependencies = []
184         self.and_dependencies = and_dependencies
185         if not or_dependencies:
186             or_dependencies = []
187         self.or_dependencies = or_dependencies
188         self._check_error = None
189
190     def __str__(self):
191         return '<{0} {1}>'.format(type(self).__name__, self.name)
192
193     def full_name(self):
194         if self.name == self.long_name:
195             return self.name
196         else:
197             return '{0} ({1})'.format(self.long_name, self.name)
198
199     def check(self):
200         if self._check_error:
201             raise self._check_error
202         try:
203             self._check_dependencies()
204             return self._check()
205         except DependencyError as e:
206             self._check_error = e  # cache for future calls
207             raise
208
209     def _check_dependencies(self):
210         for dependency in self.and_dependencies:
211             if not hasattr(dependency, 'check'):
212                 dependency = CHECKER[dependency]
213             try:
214                 dependency.check()
215             except DependencyError as e:
216                 raise DependencyError(
217                     checker=self,
218                     message=(
219                         'some dependencies for {0} were not satisfied'
220                         ).format(self.full_name()),
221                     causes=[e])
222         self.or_pass = None
223         or_errors = []
224         for dependency in self.or_dependencies:
225             if not hasattr(dependency, 'check'):
226                 dependency = CHECKER[dependency]
227             try:
228                 version = dependency.check()
229             except DependencyError as e:
230                 or_errors.append(e)
231             else:
232                 self.or_pass = {
233                     'dependency': dependency,
234                     'version': version,
235                     }
236                 break  # no need to test other dependencies
237         if self.or_dependencies and not self.or_pass:
238             raise DependencyError(
239                 checker=self,
240                 message=(
241                     '{0} requires at least one of the following dependencies'
242                     ).format(self.full_name()),
243                     causes=or_errors)
244
245     def _check(self):
246         version = self._get_version()
247         parsed_version = None
248         if hasattr(self, '_get_parsed_version'):
249             parsed_version = self._get_parsed_version()
250         if self.minimum_version:
251             self._check_version(version=version, parsed_version=parsed_version)
252         return version
253
254     def _get_version(self):
255         raise NotImplementedError(self)
256
257     def _minimum_version_string(self):
258         return self.version_delimiter.join(
259             str(part) for part in self.minimum_version)
260
261     def _check_version(self, version, parsed_version=None):
262         if not parsed_version:
263             parsed_version = self._parse_version(version=version)
264         if not parsed_version or parsed_version < self.minimum_version:
265             raise DependencyError(
266                 checker=self,
267                 message='outdated version of {0}: {1} (need >= {2})'.format(
268                     self.full_name(), version, self._minimum_version_string()))
269
270     def _parse_version(self, version):
271         if not version:
272             return None
273         parsed_version = []
274         for part in version.split(self.version_delimiter):
275             try:
276                 parsed_version.append(int(part))
277             except ValueError as e:
278                 raise DependencyError(
279                     checker=self,
280                     message=(
281                         'unparsable {0!r} in version {1} of {2}, (need >= {3})'
282                         ).format(
283                         part, version, self.full_name(),
284                         self._minimum_version_string()))# from e
285         return tuple(parsed_version)
286
287
288 class PythonDependency (Dependency):
289     def __init__(self, name='python', long_name='Python version',
290                  minimum_version=(2, 6), **kwargs):
291         super(PythonDependency, self).__init__(
292             name=name, long_name=long_name, minimum_version=minimum_version,
293             **kwargs)
294
295     def _get_version(self):
296         return _sys.version
297
298     def _get_parsed_version(self):
299         return _sys.version_info
300
301
302 CHECKER['python'] = PythonDependency()
303
304
305 class CommandDependency (Dependency):
306     exe_extension = _distutils_ccompiler.new_compiler().exe_extension
307
308     def __init__(self, command, version_options=('--version',), stdin=None,
309                  version_regexp=None, version_stream='stdout', **kwargs):
310         if 'name' not in kwargs:
311             kwargs['name'] = command
312         super(CommandDependency, self).__init__(**kwargs)
313         self.command = command
314         self.version_options = version_options
315         self.stdin = None
316         if not version_regexp:
317             regexp = r'([\d][\d{0}]*[\d])'.format(self.version_delimiter)
318             version_regexp = _re.compile(regexp)
319         self.version_regexp = version_regexp
320         self.version_stream = version_stream
321
322     def _get_version_stream(self, stdin=None, expect=(0,)):
323         if not stdin:
324             stdin = self.stdin
325         if stdin:
326             popen_stdin = _subprocess.PIPE
327         else:
328             popen_stdin = None
329         command = self.command + (self.exe_extension or '')
330         try:
331             p = _subprocess.Popen(
332                 [command] + list(self.version_options), stdin=popen_stdin,
333                 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE,
334                 close_fds=True, shell=False, universal_newlines=True)
335         except OSError as e:
336             raise DependencyError(
337                 checker=self,
338                 message="could not find '{0}' executable".format(command),
339                 )# from e
340         stdout,stderr = p.communicate(stdin)
341         status = p.wait()
342         if status not in expect:
343             lines = [
344                 "failed to execute: {0} {1}".format(
345                     command,
346                     ' '.join(_shlex.quote(arg)
347                              for arg in self.version_options)),
348                 'status: {0}'.format(status),
349                 ]
350             for name,string in [('stdout', stdout), ('stderr', stderr)]:
351                 if string:
352                     lines.extend([name + ':', string])
353             raise DependencyError(checker=self, message='\n'.join(lines))
354         for name,string in [('stdout', stdout), ('stderr', stderr)]:
355             if name == self.version_stream:
356                 return string
357         raise NotImplementedError(self.version_stream)
358
359     def _get_version(self):
360         version_stream = self._get_version_stream()
361         match = self.version_regexp.search(version_stream)
362         if not match:
363             raise DependencyError(
364                 checker=self,
365                 message='no version string in output:\n{0}'.format(
366                     version_stream))
367         return match.group(1)
368
369
370 for command,long_name,minimum_version in [
371         ('sh', 'Bourne Shell', None),
372         ('ash', 'Almquist Shell', None),
373         ('bash', 'Bourne Again Shell', None),
374         ('csh', 'C Shell', None),
375         ('ksh', 'KornShell', None),
376         ('dash', 'Debian Almquist Shell', None),
377         ('tcsh', 'TENEX C Shell', None),
378         ('zsh', 'Z Shell', None),
379         ('git', 'Git', (1, 7, 0)),
380         ('hg', 'Mercurial', (2, 0, 0)),
381         ('pip', None, None),
382         ('sqlite3', 'SQLite 3', None),
383         ('nosetests', 'Nose', (1, 0, 0)),
384         ('ipython', 'IPython script', (0, 13)),
385         ('emacs', 'Emacs', None),
386         ('xemacs', 'XEmacs', None),
387         ('vim', 'Vim', None),
388         ('vi', None, None),
389         ('nano', 'Nano', None),
390         ('gedit', None, None),
391         ('kate', 'Kate', None),
392         ('notepad++', 'Notepad++', None),
393         ('firefox', 'Firefox', None),
394         ('google-chrome', 'Google Chrome', None),
395         ('chromium', 'Chromium', None),
396         ]:
397     if not long_name:
398         long_name = command
399     CHECKER[command] = CommandDependency(
400         command=command, long_name=long_name, minimum_version=minimum_version)
401 del command, long_name, minimum_version  # cleanup namespace
402
403
404 class MakeDependency (CommandDependency):
405     makefile = '\n'.join([
406             'all:',
407             '\t@echo "MAKE_VERSION=$(MAKE_VERSION)"',
408             '\t@echo "MAKE=$(MAKE)"',
409             '',
410             ])
411
412     def _get_version(self):
413         try:
414             return super(MakeDependency, self)._get_version()
415         except DependencyError as e:
416             version_options = self.version_options
417             self.version_options = ['-f', '-']
418             try:
419                 stream = self._get_version_stream(stdin=self.makefile)
420                 info = {}
421                 for line in stream.splitlines():
422                     try:
423                         key,value = line.split('=', 1)
424                     except ValueError as ve:
425                         raise e# from NotImplementedError(stream)
426                     info[key] = value
427                 if info.get('MAKE_VERSION', None):
428                     return info['MAKE_VERSION']
429                 elif info.get('MAKE', None):
430                     return None
431                 raise e
432             finally:
433                 self.version_options = version_options
434
435
436 CHECKER['make'] = MakeDependency(command='make', minimum_version=None)
437
438
439 class EasyInstallDependency (CommandDependency):
440     def _get_version(self):
441         try:
442             return super(EasyInstallDependency, self)._get_version()
443         except DependencyError as e:
444             version_stream = self.version_stream
445             try:
446                 self.version_stream = 'stderr'
447                 stream = self._get_version_stream(expect=(1,))
448                 if 'option --version not recognized' in stream:
449                     return 'unknown (possibly Setuptools?)'
450             finally:
451                 self.version_stream = version_stream
452
453
454 CHECKER['easy_install'] = EasyInstallDependency(
455     command='easy_install', long_name='Setuptools easy_install',
456     minimum_version=None)
457
458
459 class PathCommandDependency (CommandDependency):
460     """A command that doesn't support --version or equivalent options
461
462     On some operating systems (e.g. OS X), a command's executable may
463     be hard to find, or not exist in the PATH.  Work around that by
464     just checking for the existence of a characteristic file or
465     directory.  Since the characteristic path may depend on OS,
466     installed version, etc., take a list of paths, and succeed if any
467     of them exists.
468     """
469     def __init__(self, paths, **kwargs):
470         super(PathCommandDependency, self).__init__(self, **kwargs)
471         self.paths = paths
472
473     def _get_version_stream(self, *args, **kwargs):
474         raise NotImplementedError()
475
476     def _get_version(self):
477         for path in self.paths:
478             if _os.path.exists(path):
479                 return None
480         raise DependencyError(
481             checker=self,
482             message=(
483                 'nothing exists at any of the expected paths for {0}:\n    {1}'
484                 ).format(
485                 self.full_name(),
486                 '\n    '.join(p for p in self.paths)))
487
488
489 for paths,name,long_name in [
490         ([_os.path.join(_os.sep, 'Applications', 'Sublime Text 2.app')],
491          'sublime-text', 'Sublime Text'),
492         ([_os.path.join(_os.sep, 'Applications', 'TextMate.app')],
493          'textmate', 'TextMate'),
494         ([_os.path.join(_os.sep, 'Applications', 'TextWrangler.app')],
495          'textwrangler', 'TextWrangler'),
496         ([_os.path.join(_os.sep, 'Applications', 'Xcode.app'),  # OS X >=1.7
497           _os.path.join(_os.sep, 'Developer', 'Applications', 'Xcode.app'
498                         )  # OS X 1.6,
499           ],
500          'xcode', 'Xcode'),
501         ]:
502     if not long_name:
503         long_name = name
504     CHECKER[name] = PathCommandDependency(
505         paths=paths, name=name, long_name=long_name)
506 del paths, name, long_name  # cleanup namespace
507
508
509 class PythonPackageDependency (Dependency):
510     def __init__(self, package, **kwargs):
511         if 'name' not in kwargs:
512             kwargs['name'] = package
513         if 'and_dependencies' not in kwargs:
514             kwargs['and_dependencies'] = []
515         if 'python' not in kwargs['and_dependencies']:
516             kwargs['and_dependencies'].append('python')
517         super(PythonPackageDependency, self).__init__(**kwargs)
518         self.package = package
519
520     def _get_version(self):
521         package = self._get_package(self.package)
522         return self._get_version_from_package(package)
523
524     def _get_package(self, package):
525         try:
526             return _importlib.import_module(package)
527         except ImportError as e:
528             raise DependencyError(
529                 checker=self,
530                 message="could not import the '{0}' package for {1}".format(
531                     package, self.full_name()),
532                 )# from e
533
534     def _get_version_from_package(self, package):
535         try:
536             version = package.__version__
537         except AttributeError:
538             version = None
539         return version
540
541
542 for package,name,long_name,minimum_version,and_dependencies in [
543         ('nose', None, 'Nose Python package',
544          CHECKER['nosetests'].minimum_version, None),
545         ('jinja2', 'jinja', 'Jinja', (2, 6), None),
546         ('zmq', 'pyzmq', 'PyZMQ', (2, 1, 4), None),
547         ('IPython', None, 'IPython Python package',
548          CHECKER['ipython'].minimum_version, ['jinja', 'tornado', 'pyzmq']),
549         ('numpy', None, 'NumPy', None, None),
550         ('scipy', None, 'SciPy', None, None),
551         ('matplotlib', None, 'Matplotlib', None, None),
552         ('pandas', None, 'Pandas', (0, 8), None),
553         ('sympy', None, 'SymPy', None, None),
554         ('Cython', None, None, None, None),
555         ('networkx', None, 'NetworkX', None, None),
556         ('mayavi.mlab', None, 'MayaVi', None, None),
557         ('setuptools', None, 'Setuptools', None, None),
558         ]:
559     if not name:
560         name = package
561     if not long_name:
562         long_name = name
563     kwargs = {}
564     if and_dependencies:
565         kwargs['and_dependencies'] = and_dependencies
566     CHECKER[name] = PythonPackageDependency(
567         package=package, name=name, long_name=long_name,
568         minimum_version=minimum_version, **kwargs)
569 # cleanup namespace
570 del package, name, long_name, minimum_version, and_dependencies, kwargs
571
572
573 class MercurialPythonPackage (PythonPackageDependency):
574     def _get_version(self):
575         try:  # mercurial >= 1.2
576             package = _importlib.import_module('mercurial.util')
577         except ImportError as e:  # mercurial <= 1.1.2
578             package = self._get_package('mercurial.version')
579             return package.get_version()
580         else:
581             return package.version()
582
583
584 CHECKER['mercurial'] = MercurialPythonPackage(
585     package='mercurial.util', name='mercurial',
586     long_name='Mercurial Python package',
587     minimum_version=CHECKER['hg'].minimum_version)
588
589
590 class TornadoPythonPackage (PythonPackageDependency):
591     def _get_version_from_package(self, package):
592         return package.version
593
594     def _get_parsed_version(self):
595         package = self._get_package(self.package)
596         return package.version_info
597
598
599 CHECKER['tornado'] = TornadoPythonPackage(
600     package='tornado', name='tornado', long_name='Tornado', minimum_version=(2, 0))
601
602
603 class SQLitePythonPackage (PythonPackageDependency):
604     def _get_version_from_package(self, package):
605         return _sys.version
606
607     def _get_parsed_version(self):
608         return _sys.version_info
609
610
611 CHECKER['sqlite3-python'] = SQLitePythonPackage(
612     package='sqlite3', name='sqlite3-python',
613     long_name='SQLite Python package',
614     minimum_version=CHECKER['sqlite3'].minimum_version)
615
616
617 class VirtualDependency (Dependency):
618     def _check(self):
619         return '{0} {1}'.format(
620             self.or_pass['dependency'].full_name(),
621             self.or_pass['version'])
622
623
624 for name,long_name,dependencies in [
625         ('virtual-shell', 'command line shell', (
626             'bash',
627             'dash',
628             'ash',
629             'zsh',
630             'ksh',
631             'csh',
632             'tcsh',
633             'sh',
634             )),
635         ('virtual-editor', 'text/code editor', (
636             'emacs',
637             'xemacs',
638             'vim',
639             'vi',
640             'nano',
641             'gedit',
642             'kate',
643             'notepad++',
644             'sublime-text',
645             'textmate',
646             'textwrangler',
647             )),
648         ('virtual-browser', 'web browser', (
649             'firefox',
650             'google-chrome',
651             'chromium',
652             )),
653         ('virtual-pypi-installer', 'PyPI installer', (
654             'easy_install',
655             'pip',
656             )),
657         ]:
658     CHECKER[name] = VirtualDependency(
659         name=name, long_name=long_name, or_dependencies=dependencies)
660 del name, long_name, dependencies  # cleanup namespace
661
662
663 def _print_info(key, value, indent=19):
664     print('{0}{1}: {2}'.format(key, ' '*(indent-len(key)), value))
665
666 def print_system_info():
667     print("If you do not understand why the above failures occurred,")
668     print("copy and send the *entire* output (all info above and summary")
669     print("below) to the instructor for help.")
670     print()
671     print('==================')
672     print('System information')
673     print('==================')
674     _print_info('os.name', _os.name)
675     _print_info('os.uname', _platform.uname())
676     _print_info('platform', _sys.platform)
677     _print_info('platform+', _platform.platform())
678     for pversion in (
679             'linux_distribution',
680             'mac_ver',
681             'win32_ver',
682             ):
683         value = getattr(_platform, pversion)()
684         if value[0]:
685             _print_info(pversion, value)
686     _print_info('prefix', _sys.prefix)
687     _print_info('exec_prefix', _sys.exec_prefix)
688     _print_info('executable', _sys.executable)
689     _print_info('version_info', _sys.version_info)
690     _print_info('version', _sys.version)
691     _print_info('environment', '')
692     for key,value in sorted(_os.environ.items()):
693         print('  {0}={1}'.format(key, value))
694     print('==================')
695
696 def print_suggestions(instructor_fallback=True):
697     print()
698     print('For suggestions on installing missing packages, see')
699     print('http://software-carpentry.org/setup/')
700     print('')
701     print('For instructings on installing a particular package,')
702     print('see the failure message for that package printed above.')
703     if instructor_fallback:
704         print('')
705         print('For help, email the *entire* output of this script to')
706         print('your instructor.')
707
708
709 if __name__ == '__main__':
710     try:
711         passed = check(_sys.argv[1:])
712     except InvalidCheck as e:
713         print("I don't know how to check for {0!r}".format(e.check))
714         print('I do know how to check for:')
715         for key,checker in sorted(CHECKER.items()):
716             if checker.long_name != checker.name:
717                 print('  {0} {1}({2})'.format(
718                         key, ' '*(20-len(key)), checker.long_name))
719             else:
720                 print('  {0}'.format(key))
721         _sys.exit(1)
722     if not passed:
723         print()
724         print_system_info()
725         print_suggestions(instructor_fallback=True)
726         _sys.exit(1)