README.md: Remove nesting setup/
[swc-setup-installation-test.git] / swc-installation-test-2.py
index b479c98bd0bc9e1eae09d242086b41e152631404..7f55b337c40a909f3b4c480a79912d1d9ec5837b 100755 (executable)
@@ -25,6 +25,7 @@ dependency.
 from __future__ import print_function  # for Python 2.6 compatibility
 
 import distutils.ccompiler as _distutils_ccompiler
+import fnmatch as _fnmatch
 try:  # Python 2.7 and 3.x
     import importlib as _importlib
 except ImportError:  # Python 2.6 and earlier
@@ -45,6 +46,10 @@ import re as _re
 import shlex as _shlex
 import subprocess as _subprocess
 import sys as _sys
+try:  # Python 3.x
+    import urllib.parse as _urllib_parse
+except ImportError:  # Python 2.x
+    import urllib as _urllib_parse  # for quote()
 
 
 if not hasattr(_shlex, 'quote'):  # Python versions older than 3.3
@@ -66,23 +71,30 @@ CHECKS = [
 # Version control
     'git',
     'hg',              # Command line tool
-    'mercurial',       # Python package
+    #'mercurial',       # Python package
+    'EasyMercurial',
 # Build tools and packaging
     'make',
-    'easy_install',
+    'virtual-pypi-installer',
     'setuptools',
+    #'xcode',
 # Testing
     'nosetests',       # Command line tool
     'nose',            # Python package
+    'py.test',         # Command line tool
+    'pytest',          # Python package
 # SQL
     'sqlite3',         # Command line tool
     'sqlite3-python',  # Python package
 # Python
     'python',
-    'IPython',
+    'ipython',         # Command line tool
+    'IPython',         # Python package
+    'argparse',        # Useful for utility scripts
     'numpy',
     'scipy',
     'matplotlib',
+    'pandas',
     'sympy',
     'Cython',
     'networkx',
@@ -91,6 +103,10 @@ CHECKS = [
 
 CHECKER = {}
 
+_ROOT_PATH = _os.sep
+if _platform.system() == 'win32':
+    _ROOT_PATH = 'c:\\'
+
 
 class InvalidCheck (KeyError):
     def __init__(self, check):
@@ -102,23 +118,113 @@ class InvalidCheck (KeyError):
 
 
 class DependencyError (Exception):
+    _default_url = 'http://software-carpentry.org/setup/'
+    _setup_urls = {  # (system, version, package) glob pairs
+        ('*', '*', 'Cython'): 'http://docs.cython.org/src/quickstart/install.html',
+        ('Linux', '*', 'EasyMercurial'): 'http://easyhg.org/download.html#download-linux',
+        ('Darwin', '*', 'EasyMercurial'): 'http://easyhg.org/download.html#download-mac',
+        ('Windows', '*', 'EasyMercurial'): 'http://easyhg.org/download.html#download-windows',
+        ('*', '*', 'EasyMercurial'): 'http://easyhg.org/download.html',
+        ('*', '*', 'argparse'): 'https://pypi.python.org/pypi/argparse#installation',
+        ('*', '*', 'ash'): 'http://www.in-ulm.de/~mascheck/various/ash/',
+        ('*', '*', 'bash'): 'http://www.gnu.org/software/bash/manual/html_node/Basic-Installation.html#Basic-Installation',
+        ('Linux', '*', 'chromium'): 'http://code.google.com/p/chromium/wiki/LinuxBuildInstructions',
+        ('Darwin', '*', 'chromium'): 'http://code.google.com/p/chromium/wiki/MacBuildInstructions',
+        ('Windows', '*', 'chromium'): 'http://www.chromium.org/developers/how-tos/build-instructions-windows',
+        ('*', '*', 'chromium'): 'http://www.chromium.org/developers/how-tos',
+        ('Windows', '*', 'emacs'): 'http://www.gnu.org/software/emacs/windows/Installing-Emacs.html',
+        ('*', '*', 'emacs'): 'http://www.gnu.org/software/emacs/#Obtaining',
+        ('*', '*', 'firefox'): 'http://www.mozilla.org/en-US/firefox/new/',
+        ('Linux', '*', 'gedit'): 'http://www.linuxfromscratch.org/blfs/view/svn/gnome/gedit.html',
+        ('*', '*', 'git'): 'http://git-scm.com/downloads',
+        ('*', '*', 'google-chrome'): 'https://www.google.com/intl/en/chrome/browser/',
+        ('*', '*', 'hg'): 'http://mercurial.selenic.com/',
+        ('*', '*', 'mercurial'): 'http://mercurial.selenic.com/',
+        ('*', '*', 'IPython'): 'http://ipython.org/install.html',
+        ('*', '*', 'ipython'): 'http://ipython.org/install.html',
+        ('*', '*', 'jinja'): 'http://jinja.pocoo.org/docs/intro/#installation',
+        ('*', '*', 'kate'): 'http://kate-editor.org/get-it/',
+        ('*', '*', 'make'): 'http://www.gnu.org/software/make/',
+        ('Darwin', '*', 'matplotlib'): 'http://matplotlib.org/users/installing.html#building-on-osx',
+        ('Windows', '*', 'matplotlib'): 'http://matplotlib.org/users/installing.html#installing-on-windows',
+        ('*', '*', 'matplotlib'): 'http://matplotlib.org/users/installing.html#installing',
+        ('*', '*', 'mayavi.mlab'): 'http://docs.enthought.com/mayavi/mayavi/installation.html',
+        ('*', '*', 'nano'): 'http://www.nano-editor.org/dist/latest/faq.html#3',
+        ('*', '*', 'networkx'): 'http://networkx.github.com/documentation/latest/install.html#installing',
+        ('*', '*', 'nose'): 'https://nose.readthedocs.org/en/latest/#installation-and-quick-start',
+        ('*', '*', 'nosetests'): 'https://nose.readthedocs.org/en/latest/#installation-and-quick-start',
+        ('*', '*', 'notepad++'): 'http://notepad-plus-plus.org/download/v6.3.html',
+        ('*', '*', 'numpy'): 'http://docs.scipy.org/doc/numpy/user/install.html',
+        ('*', '*', 'pandas'): 'http://pandas.pydata.org/pandas-docs/stable/install.html',
+        ('*', '*', 'pip'): 'http://www.pip-installer.org/en/latest/installing.html',
+        ('*', '*', 'pytest'): 'http://pytest.org/latest/getting-started.html',
+        ('*', '*', 'python'): 'http://www.python.org/download/releases/2.7.3/#download',
+        ('*', '*', 'pyzmq'): 'https://github.com/zeromq/pyzmq/wiki/Building-and-Installing-PyZMQ',
+        ('*', '*', 'py.test'): 'http://pytest.org/latest/getting-started.html',
+        ('Linux', '*', 'scipy'): 'http://www.scipy.org/Installing_SciPy/Linux',
+        ('Darwin', '*', 'scipy'): 'http://www.scipy.org/Installing_SciPy/Mac_OS_X',
+        ('Windows', '*', 'scipy'): 'http://www.scipy.org/Installing_SciPy/Windows',
+        ('*', '*', 'scipy'): 'http://www.scipy.org/Installing_SciPy',
+        ('*', '*', 'setuptools'): 'https://pypi.python.org/pypi/setuptools#installation-instructions',
+        ('*', '*', 'sqlite3'): 'http://www.sqlite.org/download.html',
+        ('*', '*', 'sublime-text'): 'http://www.sublimetext.com/2',
+        ('*', '*', 'sympy'): 'http://docs.sympy.org/dev/install.html',
+        ('Darwin', '*', 'textmate'): 'http://macromates.com/',
+        ('Darwin', '*', 'textwrangler'): 'http://www.barebones.com/products/textwrangler/download.html',
+        ('*', '*', 'tornado'): 'http://www.tornadoweb.org/',
+        ('*', '*', 'vim'): 'http://www.vim.org/download.php',
+        ('Darwin', '*', 'xcode'): 'https://developer.apple.com/xcode/',
+        ('*', '*', 'xemacs'): 'http://www.us.xemacs.org/Install/',
+        ('*', '*', 'zsh'): 'http://www.zsh.org/',
+        }
+
     def _get_message(self):
         return self._message
     def _set_message(self, message):
         self._message = message
     message = property(_get_message, _set_message)
 
-    def __init__(self, checker, message):
+    def __init__(self, checker, message, causes=None):
         super(DependencyError, self).__init__(message)
         self.checker = checker
         self.message = message
+        if causes is None:
+            causes = []
+        self.causes = causes
+
+    def get_url(self):
+        system = _platform.system()
+        version = None
+        for pversion in (
+            'linux_distribution',
+            'mac_ver',
+            'win32_ver',
+            ):
+            value = getattr(_platform, pversion)()
+            if value[0]:
+                version = value[0]
+                break
+        package = self.checker.name
+        for (s,v,p),url in self._setup_urls.items():
+            if (_fnmatch.fnmatch(system, s) and
+                    _fnmatch.fnmatch(version, v) and
+                    _fnmatch.fnmatch(package, p)):
+                return url
+        return self._default_url
 
     def __str__(self):
-        url = 'http://software-carpentry.org/setup/'  # TODO: per-package URL
-        return 'check for {0} failed:\n{1}\n{2}\n{3}'.format(
-            self.checker.full_name(), self.message,
-            'For instructions on installing an up-to-date version, see',
-            url)
+        url = self.get_url()
+        lines = [
+            'check for {0} failed:'.format(self.checker.full_name()),
+            '  ' + self.message,
+            '  For instructions on installing an up-to-date version, see',
+            '  ' + url,
+            ]
+        if self.causes:
+            lines.append('  causes:')
+            for cause in self.causes:
+                lines.extend('  ' + line for line in str(cause).splitlines())
+        return '\n'.join(lines)
 
 
 def check(checks=None):
@@ -197,15 +303,24 @@ class Dependency (object):
         for dependency in self.and_dependencies:
             if not hasattr(dependency, 'check'):
                 dependency = CHECKER[dependency]
-            dependency.check()
-        self.or_pass = or_error = None
+            try:
+                dependency.check()
+            except DependencyError as e:
+                raise DependencyError(
+                    checker=self,
+                    message=(
+                        'some dependencies for {0} were not satisfied'
+                        ).format(self.full_name()),
+                    causes=[e])
+        self.or_pass = None
+        or_errors = []
         for dependency in self.or_dependencies:
             if not hasattr(dependency, 'check'):
                 dependency = CHECKER[dependency]
             try:
                 version = dependency.check()
             except DependencyError as e:
-                or_error = e
+                or_errors.append(e)
             else:
                 self.or_pass = {
                     'dependency': dependency,
@@ -213,7 +328,12 @@ class Dependency (object):
                     }
                 break  # no need to test other dependencies
         if self.or_dependencies and not self.or_pass:
-            raise or_error
+            raise DependencyError(
+                checker=self,
+                message=(
+                    '{0} requires at least one of the following dependencies'
+                    ).format(self.full_name()),
+                    causes=or_errors)
 
     def _check(self):
         version = self._get_version()
@@ -227,6 +347,10 @@ class Dependency (object):
     def _get_version(self):
         raise NotImplementedError(self)
 
+    def _minimum_version_string(self):
+        return self.version_delimiter.join(
+            str(part) for part in self.minimum_version)
+
     def _check_version(self, version, parsed_version=None):
         if not parsed_version:
             parsed_version = self._parse_version(version=version)
@@ -234,9 +358,7 @@ class Dependency (object):
             raise DependencyError(
                 checker=self,
                 message='outdated version of {0}: {1} (need >= {2})'.format(
-                    self.full_name(), version,
-                    self.version_delimiter.join(
-                        str(part) for part in self.minimum_version)))
+                    self.full_name(), version, self._minimum_version_string()))
 
     def _parse_version(self, version):
         if not version:
@@ -246,7 +368,13 @@ class Dependency (object):
             try:
                 parsed_version.append(int(part))
             except ValueError as e:
-                raise NotImplementedError((version, part))# from e
+                raise DependencyError(
+                    checker=self,
+                    message=(
+                        'unparsable {0!r} in version {1} of {2}, (need >= {3})'
+                        ).format(
+                        part, version, self.full_name(),
+                        self._minimum_version_string()))# from e
         return tuple(parsed_version)
 
 
@@ -270,12 +398,14 @@ CHECKER['python'] = PythonDependency()
 class CommandDependency (Dependency):
     exe_extension = _distutils_ccompiler.new_compiler().exe_extension
 
-    def __init__(self, command, version_options=('--version',), stdin=None,
-                 version_regexp=None, version_stream='stdout', **kwargs):
+    def __init__(self, command, paths=None, version_options=('--version',),
+                 stdin=None, version_regexp=None, version_stream='stdout',
+                 **kwargs):
         if 'name' not in kwargs:
             kwargs['name'] = command
         super(CommandDependency, self).__init__(**kwargs)
         self.command = command
+        self.paths = paths
         self.version_options = version_options
         self.stdin = None
         if not version_regexp:
@@ -284,19 +414,21 @@ class CommandDependency (Dependency):
         self.version_regexp = version_regexp
         self.version_stream = version_stream
 
-    def _get_version_stream(self, stdin=None, expect=(0,)):
+    def _get_command_version_stream(self, command=None, stdin=None,
+                                    expect=(0,)):
+        if command is None:
+            command = self.command + (self.exe_extension or '')
         if not stdin:
             stdin = self.stdin
         if stdin:
             popen_stdin = _subprocess.PIPE
         else:
             popen_stdin = None
-        command = self.command + (self.exe_extension or '')
         try:
             p = _subprocess.Popen(
                 [command] + list(self.version_options), stdin=popen_stdin,
                 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE,
-                close_fds=True, shell=False, universal_newlines=True)
+                universal_newlines=True)
         except OSError as e:
             raise DependencyError(
                 checker=self,
@@ -318,9 +450,32 @@ class CommandDependency (Dependency):
             raise DependencyError(checker=self, message='\n'.join(lines))
         for name,string in [('stdout', stdout), ('stderr', stderr)]:
             if name == self.version_stream:
+                if not string:
+                    raise DependencyError(
+                        checker=self,
+                        message='empty version stream on {0} for {1}'.format(
+                            self.version_stream, command))
                 return string
         raise NotImplementedError(self.version_stream)
 
+    def _get_version_stream(self, **kwargs):
+        paths = [self.command + (self.exe_extension or '')]
+        if self.exe_extension:
+            paths.append(self.command)  # also look at the extension-less path
+        if self.paths:
+            paths.extend(self.paths)
+        or_errors = []
+        for path in paths:
+            try:
+                return self._get_command_version_stream(command=path, **kwargs)
+            except DependencyError as e:
+                or_errors.append(e)
+        raise DependencyError(
+            checker=self,
+            message='errors finding {0} version'.format(
+                self.full_name()),
+            causes=or_errors)
+
     def _get_version(self):
         version_stream = self._get_version_stream()
         match = self.version_regexp.search(version_stream)
@@ -332,35 +487,54 @@ class CommandDependency (Dependency):
         return match.group(1)
 
 
-for command,long_name,minimum_version in [
-        ('sh', 'Bourne Shell', None),
-        ('ash', 'Almquist Shell', None),
-        ('bash', 'Bourne Again Shell', None),
-        ('csh', 'C Shell', None),
-        ('ksh', 'KornShell', None),
-        ('dash', 'Debian Almquist Shell', None),
-        ('tcsh', 'TENEX C Shell', None),
-        ('zsh', 'Z Shell', None),
-        ('git', 'Git', (1, 7, 0)),
-        ('hg', 'Mercurial', (2, 0, 0)),
-        ('sqlite3', 'SQLite 3', None),
-        ('nosetests', 'Nose', (1, 0, 0)),
-        ('emacs', 'Emacs', None),
-        ('xemacs', 'XEmacs', None),
-        ('vim', 'Vim', None),
-        ('vi', None, None),
-        ('nano', 'Nano', None),
-        ('kate', 'Kate', None),
-        ('notepad++', 'Notepad++', None),
-        ('firefox', 'Firefox', None),
-        ('google-chrome', 'Google Chrome', None),
-        ('chromium', 'Chromium', None),
+def _program_files_paths(*args):
+    "Utility for generating MS Windows search paths"
+    pf = _os.environ.get('ProgramFiles', '/usr/bin')
+    pfx86 = _os.environ.get('ProgramFiles(x86)', pf)
+    paths = [_os.path.join(pf, *args)]
+    if pfx86 != pf:
+        paths.append(_os.path.join(pfx86, *args))
+    return paths
+
+
+for command,long_name,minimum_version,paths in [
+        ('sh', 'Bourne Shell', None, None),
+        ('ash', 'Almquist Shell', None, None),
+        ('bash', 'Bourne Again Shell', None, None),
+        ('csh', 'C Shell', None, None),
+        ('ksh', 'KornShell', None, None),
+        ('dash', 'Debian Almquist Shell', None, None),
+        ('tcsh', 'TENEX C Shell', None, None),
+        ('zsh', 'Z Shell', None, None),
+        ('git', 'Git', (1, 7, 0), None),
+        ('hg', 'Mercurial', (2, 0, 0), None),
+        ('EasyMercurial', None, (1, 3), None),
+        ('pip', None, None, None),
+        ('sqlite3', 'SQLite 3', None, None),
+        ('nosetests', 'Nose', (1, 0, 0), None),
+        ('ipython', 'IPython script', (0, 13), None),
+        ('emacs', 'Emacs', None, None),
+        ('xemacs', 'XEmacs', None, None),
+        ('vim', 'Vim', None, None),
+        ('vi', None, None, None),
+        ('nano', 'Nano', None, None),
+        ('gedit', None, None, None),
+        ('kate', 'Kate', None, None),
+        ('notepad++', 'Notepad++', None,
+         _program_files_paths('Notepad++', 'notepad++.exe')),
+        ('firefox', 'Firefox', None,
+         _program_files_paths('Mozilla Firefox', 'firefox.exe')),
+        ('google-chrome', 'Google Chrome', None,
+         _program_files_paths('Google', 'Chrome', 'Application', 'chrome.exe')
+         ),
+        ('chromium', 'Chromium', None, None),
         ]:
     if not long_name:
         long_name = command
     CHECKER[command] = CommandDependency(
-        command=command, long_name=long_name, minimum_version=minimum_version)
-del command, long_name, minimum_version  # cleanup namespace
+        command=command, paths=paths, long_name=long_name,
+        minimum_version=minimum_version)
+del command, long_name, minimum_version, paths  # cleanup namespace
 
 
 class MakeDependency (CommandDependency):
@@ -418,6 +592,62 @@ CHECKER['easy_install'] = EasyInstallDependency(
     minimum_version=None)
 
 
+CHECKER['py.test'] = CommandDependency(
+    command='py.test', version_stream='stderr',
+    minimum_version=None)
+
+
+class PathCommandDependency (CommandDependency):
+    """A command that doesn't support --version or equivalent options
+
+    On some operating systems (e.g. OS X), a command's executable may
+    be hard to find, or not exist in the PATH.  Work around that by
+    just checking for the existence of a characteristic file or
+    directory.  Since the characteristic path may depend on OS,
+    installed version, etc., take a list of paths, and succeed if any
+    of them exists.
+    """
+    def _get_command_version_stream(self, *args, **kwargs):
+        raise NotImplementedError()
+
+    def _get_version_stream(self, *args, **kwargs):
+        raise NotImplementedError()
+
+    def _get_version(self):
+        for path in self.paths:
+            if _os.path.exists(path):
+                return None
+        raise DependencyError(
+            checker=self,
+            message=(
+                'nothing exists at any of the expected paths for {0}:\n    {1}'
+                ).format(
+                self.full_name(),
+                '\n    '.join(p for p in self.paths)))
+
+
+for paths,name,long_name in [
+        ([_os.path.join(_ROOT_PATH, 'Applications', 'Sublime Text 2.app')],
+         'sublime-text', 'Sublime Text'),
+        ([_os.path.join(_ROOT_PATH, 'Applications', 'TextMate.app')],
+         'textmate', 'TextMate'),
+        ([_os.path.join(_ROOT_PATH, 'Applications', 'TextWrangler.app')],
+         'textwrangler', 'TextWrangler'),
+        ([_os.path.join(_ROOT_PATH, 'Applications', 'Safari.app')],
+         'safari', 'Safari'),
+        ([_os.path.join(_ROOT_PATH, 'Applications', 'Xcode.app'),  # OS X >=1.7
+          _os.path.join(_ROOT_PATH, 'Developer', 'Applications', 'Xcode.app'
+                        )  # OS X 1.6,
+          ],
+         'xcode', 'Xcode'),
+        ]:
+    if not long_name:
+        long_name = name
+    CHECKER[name] = PathCommandDependency(
+        command=None, paths=paths, name=name, long_name=long_name)
+del paths, name, long_name  # cleanup namespace
+
+
 class PythonPackageDependency (Dependency):
     def __init__(self, package, **kwargs):
         if 'name' not in kwargs:
@@ -451,27 +681,38 @@ class PythonPackageDependency (Dependency):
         return version
 
 
-for package,name,long_name,minimum_version in [
+for package,name,long_name,minimum_version,and_dependencies in [
         ('nose', None, 'Nose Python package',
-         CHECKER['nosetests'].minimum_version),
-        ('IPython', None, None, None),
-        ('numpy', None, 'NumPy', None),
-        ('scipy', None, 'SciPy', None),
-        ('matplotlib', None, 'Matplotlib', None),
-        ('sympy', None, 'SymPy', None),
-        ('Cython', None, None, None),
-        ('networkx', None, 'NetworkX', None),
-        ('mayavi.mlab', None, 'MayaVi', None),
-        ('setuptools', None, 'Setuptools', None),
+         CHECKER['nosetests'].minimum_version, None),
+        ('pytest', None, 'pytest Python package',
+         CHECKER['py.test'].minimum_version, None),
+        ('jinja2', 'jinja', 'Jinja', (2, 6), None),
+        ('zmq', 'pyzmq', 'PyZMQ', (2, 1, 4), None),
+        ('IPython', None, 'IPython Python package',
+         CHECKER['ipython'].minimum_version, ['jinja', 'tornado', 'pyzmq']),
+        ('argparse', None, 'Argparse', None, None),
+        ('numpy', None, 'NumPy', None, None),
+        ('scipy', None, 'SciPy', None, None),
+        ('matplotlib', None, 'Matplotlib', None, None),
+        ('pandas', None, 'Pandas', (0, 8), None),
+        ('sympy', None, 'SymPy', None, None),
+        ('Cython', None, None, None, None),
+        ('networkx', None, 'NetworkX', None, None),
+        ('mayavi.mlab', None, 'MayaVi', None, None),
+        ('setuptools', None, 'Setuptools', None, None),
         ]:
     if not name:
         name = package
     if not long_name:
         long_name = name
+    kwargs = {}
+    if and_dependencies:
+        kwargs['and_dependencies'] = and_dependencies
     CHECKER[name] = PythonPackageDependency(
         package=package, name=name, long_name=long_name,
-        minimum_version=minimum_version)
-del package, name, long_name, minimum_version  # cleanup namespace
+        minimum_version=minimum_version, **kwargs)
+# cleanup namespace
+del package, name, long_name, minimum_version, and_dependencies, kwargs
 
 
 class MercurialPythonPackage (PythonPackageDependency):
@@ -491,6 +732,19 @@ CHECKER['mercurial'] = MercurialPythonPackage(
     minimum_version=CHECKER['hg'].minimum_version)
 
 
+class TornadoPythonPackage (PythonPackageDependency):
+    def _get_version_from_package(self, package):
+        return package.version
+
+    def _get_parsed_version(self):
+        package = self._get_package(self.package)
+        return package.version_info
+
+
+CHECKER['tornado'] = TornadoPythonPackage(
+    package='tornado', name='tornado', long_name='Tornado', minimum_version=(2, 0))
+
+
 class SQLitePythonPackage (PythonPackageDependency):
     def _get_version_from_package(self, package):
         return _sys.version
@@ -505,6 +759,61 @@ CHECKER['sqlite3-python'] = SQLitePythonPackage(
     minimum_version=CHECKER['sqlite3'].minimum_version)
 
 
+class UserTaskDependency (Dependency):
+    "Prompt the user to complete a task and check for success"
+    def __init__(self, prompt, **kwargs):
+        super(UserTaskDependency, self).__init__(**kwargs)
+        self.prompt = prompt
+
+    def _check(self):
+        if _sys.version_info >= (3, ):
+            result = input(self.prompt)
+        else:  # Python 2.x
+            result = raw_input(self.prompt)
+        return self._check_result(result)
+
+    def _check_result(self, result):
+        raise NotImplementedError()
+
+
+class EditorTaskDependency (UserTaskDependency):
+    def __init__(self, **kwargs):
+        self.path = _os.path.expanduser(_os.path.join(
+                '~', 'swc-installation-test.txt'))
+        self.contents = 'Hello, world!'
+        super(EditorTaskDependency, self).__init__(
+            prompt=(
+                'Open your favorite text editor and create the file\n'
+                '  {0}\n'
+                'containing the line:\n'
+                '  {1}\n'
+                'Press enter here after you have done this.\n'
+                'You may remove the file after you have finished testing.'
+                ).format(self.path, self.contents),
+            **kwargs)
+
+    def _check_result(self, result):
+        message = None
+        try:
+            with open(self.path, 'r') as f:
+                contents = f.read()
+        except IOError as e:
+            raise DependencyError(
+                checker=self,
+                message='could not open {0!r}: {1}'.format(self.path, e)
+                )# from e
+        if contents.strip() != self.contents:
+            raise DependencyError(
+                checker=self,
+                message=(
+                    'file contents ({0!r}) did not match the expected {1!r}'
+                    ).format(contents, self.contents))
+
+
+CHECKER['other-editor'] = EditorTaskDependency(
+    name='other-editor', long_name='')
+
+
 class VirtualDependency (Dependency):
     def _check(self):
         return '{0} {1}'.format(
@@ -512,8 +821,8 @@ class VirtualDependency (Dependency):
             self.or_pass['version'])
 
 
-for name,dependencies in [
-        ('virtual-shell', (
+for name,long_name,dependencies in [
+        ('virtual-shell', 'command line shell', (
             'bash',
             'dash',
             'ash',
@@ -523,24 +832,34 @@ for name,dependencies in [
             'tcsh',
             'sh',
             )),
-        ('virtual-editor', (
+        ('virtual-editor', 'text/code editor', (
             'emacs',
             'xemacs',
             'vim',
             'vi',
             'nano',
+            'gedit',
             'kate',
             'notepad++',
+            'sublime-text',
+            'textmate',
+            'textwrangler',
+            'other-editor',  # last because it requires user interaction
             )),
-        ('virtual-browser', (
+        ('virtual-browser', 'web browser', (
             'firefox',
             'google-chrome',
             'chromium',
+            'safari',
+            )),
+        ('virtual-pypi-installer', 'PyPI installer', (
+            'easy_install',
+            'pip',
             )),
         ]:
     CHECKER[name] = VirtualDependency(
-        name=name, long_name=name, or_dependencies=dependencies)
-del name, dependencies  # cleanup namespace
+        name=name, long_name=long_name, or_dependencies=dependencies)
+del name, long_name, dependencies  # cleanup namespace
 
 
 def _print_info(key, value, indent=19):
@@ -590,8 +909,18 @@ def print_suggestions(instructor_fallback=True):
 
 
 if __name__ == '__main__':
+    import optparse as _optparse
+
+    parser = _optparse.OptionParser(usage='%prog [options] [check...]')
+    epilog = __doc__
+    parser.format_epilog = lambda formatter: '\n' + epilog
+    parser.add_option(
+        '-v', '--verbose', action='store_true',
+        help=('print additional information to help troubleshoot '
+              'installation issues'))
+    options,args = parser.parse_args()
     try:
-        passed = check(_sys.argv[1:])
+        passed = check(args)
     except InvalidCheck as e:
         print("I don't know how to check for {0!r}".format(e.check))
         print('I do know how to check for:')
@@ -603,6 +932,8 @@ if __name__ == '__main__':
                 print('  {0}'.format(key))
         _sys.exit(1)
     if not passed:
-        print()
-        print_system_info()
-        print_suggestions(instructor_fallback=True)
+        if options.verbose:
+            print()
+            print_system_info()
+            print_suggestions(instructor_fallback=True)
+        _sys.exit(1)