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