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