class DocTestController(SageObject): """ This class controls doctesting of files. After creating it with appropriate options, call the :meth:run() method to run the doctests. """ def __init__(self, options, args): """ Initialization. INPUT: - options -- either options generated from the command line by SAGE_LOCAL/bin/sage-runtests or a DocTestDefaults object (possibly with some entries modified) - args -- a list of filenames to doctest EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC DocTest Controller """ # First we modify options to take environment variables into # account and check compatibility of the user's specified # options. if options.timeout < 0: if options.gdb or options.debug: # Interactive debuggers: "infinite" timeout options.timeout = 0 elif options.valgrind or options.massif or options.cachegrind or options.omega: # Non-interactive debuggers: 48 hours options.timeout = int( os.getenv('SAGE_TIMEOUT_VALGRIND', 48 * 60 * 60)) elif options.long: options.timeout = int(os.getenv('SAGE_TIMEOUT_LONG', 30 * 60)) else: options.timeout = int(os.getenv('SAGE_TIMEOUT', 5 * 60)) if options.nthreads == 0: options.nthreads = int(os.getenv('SAGE_NUM_THREADS_PARALLEL', 1)) if options.failed and not (args or options.new or options.sagenb): # If the user doesn't specify any files then we rerun all failed files. options.all = True if options.global_iterations == 0: options.global_iterations = int( os.environ.get('SAGE_TEST_GLOBAL_ITER', 1)) if options.file_iterations == 0: options.file_iterations = int(os.environ.get('SAGE_TEST_ITER', 1)) if options.debug and options.nthreads > 1: print( "Debugging requires single-threaded operation, setting number of threads to 1." ) options.nthreads = 1 if options.serial: options.nthreads = 1 self.options = options self.files = args if options.all and options.logfile is None: options.logfile = os.path.join(os.environ['SAGE_TESTDIR'], 'test.log') if options.logfile: try: self.logfile = open(options.logfile, 'a') except IOError: print "Unable to open logfile at %s\nProceeding without logging." % ( options.logfile) self.logfile = None else: self.logfile = None self.stats = {} self.load_stats(options.stats_path) def _repr_(self): """ String representation. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: repr(DC) # indirect doctest 'DocTest Controller' """ return "DocTest Controller" def load_stats(self, filename): """ Load stats from the most recent run(s). Stats are stored as a JSON file, and include information on which files failed tests and the walltime used for execution of the doctests. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: import json sage: filename = tmp_filename() sage: with open(filename, 'w') as stats_file: ... json.dump({'sage.doctest.control':{u'walltime':1.0r}}, stats_file) sage: DC.load_stats(filename) sage: DC.stats['sage.doctest.control'] {u'walltime': 1.0} If the file doesn't exist, nothing happens. If there is an error, print a message. In any case, leave the stats alone:: sage: d = tmp_dir() sage: DC.load_stats(os.path.join(d)) # Cannot read a directory Error loading stats from ... sage: DC.load_stats(os.path.join(d, "no_such_file")) sage: DC.stats['sage.doctest.control'] {u'walltime': 1.0} """ # Simply ignore non-existing files if not os.path.exists(filename): return try: with open(filename) as stats_file: self.stats.update(json.load(stats_file)) except StandardError: self.log("Error loading stats from %s" % filename) def save_stats(self, filename): """ Save stats from the most recent run as a JSON file. WARNING: This function overwrites the file. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC.stats['sage.doctest.control'] = {u'walltime':1.0r} sage: filename = tmp_filename() sage: DC.save_stats(filename) sage: import json sage: D = json.load(open(filename)) sage: D['sage.doctest.control'] {u'walltime': 1.0} """ with open(filename, 'w') as stats_file: json.dump(self.stats, stats_file) def log(self, s, end="\n"): """ Logs the string ``s + end`` (where ``end`` is a newline by default) to the logfile and prints it to the standard output. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DD = DocTestDefaults(logfile=tmp_filename()) sage: DC = DocTestController(DD, []) sage: DC.log("hello world") hello world sage: DC.logfile.close() sage: with open(DD.logfile) as logger: print logger.read() hello world """ s += end if self.logfile is not None: self.logfile.write(s) sys.stdout.write(s) def test_safe_directory(self, dir=None): """ Test that the given directory is safe to run Python code from. We use the check added to Python for this, which gives a warning when the current directory is considered unsafe. We promote this warning to an error with ``-Werror``. See ``sage/tests/cmdline.py`` for a doctest that this works, see also :trac:`13579`. TESTS:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DD = DocTestDefaults() sage: DC = DocTestController(DD, []) sage: DC.test_safe_directory() sage: d = os.path.join(tmp_dir(), "test") sage: os.mkdir(d) sage: os.chmod(d, 0o777) sage: DC.test_safe_directory(d) Traceback (most recent call last): ... RuntimeError: refusing to run doctests... """ import subprocess with open(os.devnull, 'w') as dev_null: if subprocess.call(['python', '-Werror', '-c', ''], stdout=dev_null, stderr=dev_null, cwd=dir) != 0: raise RuntimeError( "refusing to run doctests from the current " "directory '{}' since untrusted users could put files in " "this directory, making it unsafe to run Sage code from". format(os.getcwd())) def create_run_id(self): """ Creates the run id. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC.create_run_id() Running doctests with ID ... """ self.run_id = time.strftime( '%Y-%m-%d-%H-%M-%S-') + "%08x" % random.getrandbits(32) from sage.version import version self.log("Running doctests with ID %s." % self.run_id) def add_files(self): """ Checks for the flags '--all', '--new' and '--sagenb'. For each one present, this function adds the appropriate directories and files to the todo list. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: log_location = os.path.join(SAGE_TMP, 'control_dt_log.log') sage: DD = DocTestDefaults(all=True, logfile=log_location) sage: DC = DocTestController(DD, []) sage: DC.add_files() Doctesting entire Sage library. sage: os.path.join(SAGE_SRC, 'sage') in DC.files True :: sage: DD = DocTestDefaults(new = True) sage: DC = DocTestController(DD, []) sage: DC.add_files() Doctesting files changed since last HG commit. sage: len(DC.files) == len([L for L in hg_sage('status', interactive=False, debug=False)[0].split('\n') if len(L.split()) ==2 and L.split()[0] in ['M','A']]) True :: sage: DD = DocTestDefaults(sagenb = True) sage: DC = DocTestController(DD, []) sage: DC.add_files() Doctesting the Sage notebook. sage: DC.files[0][-6:] 'sagenb' """ opj = os.path.join from sage.env import SAGE_SRC as base if self.options.all: self.log("Doctesting entire Sage library.") from glob import glob self.files.append(opj(base, 'sage')) self.files.append(opj(base, 'doc', 'common')) self.files.extend(glob(opj(base, 'doc', '[a-z][a-z]'))) self.options.sagenb = True elif self.options.new: self.log("Doctesting files changed since last HG commit.") import sage.all_cmdline from sage.misc.hg import hg_sage for X in hg_sage('status', interactive=False, debug=False)[0].split('\n'): tup = X.split() if len(tup) != 2: continue c, filename = tup if c in ['M', 'A']: filename = opj(base, filename) self.files.append(filename) if self.options.sagenb: if not self.options.all: self.log("Doctesting the Sage notebook.") from pkg_resources import Requirement, working_set sagenb_loc = working_set.find(Requirement.parse('sagenb')).location self.files.append(opj(sagenb_loc, 'sagenb')) def expand_files_into_sources(self): """ Expands ``self.files``, which may include directories, into a list of :class:`sage.doctest.FileDocTestSource` This function also handles the optional command line option. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: dirname = os.path.join(SAGE_SRC, 'sage', 'doctest') sage: DD = DocTestDefaults(optional='all') sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: len(DC.sources) 9 sage: DC.sources[0].optional True :: sage: DD = DocTestDefaults(optional='magma,guava') sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: sorted(list(DC.sources[0].optional)) ['guava', 'magma'] """ def skipdir(dirname): if os.path.exists(os.path.join(dirname, "nodoctest.py")): return True # Workaround for https://github.com/sagemath/sagenb/pull/84 if dirname.endswith(os.path.join(os.sep, 'sagenb', 'data')): return True return False def skipfile(filename): base, ext = os.path.splitext(filename) if ext not in ('.py', '.pyx', '.pxi', '.sage', '.spyx', '.rst', '.tex'): return True with open(filename) as F: return 'nodoctest' in F.read(50) def expand(): for path in self.files: if os.path.isdir(path): for root, dirs, files in os.walk(path): for dir in list(dirs): if dir[0] == "." or skipdir(os.path.join( root, dir)): dirs.remove(dir) for file in files: if not skipfile(os.path.join(root, file)): yield os.path.join(root, file) else: # the user input this file explicitly, so we don't skip it yield path if self.options.optional == 'all': optionals = True else: optionals = set(self.options.optional.lower().split(',')) self.sources = [ FileDocTestSource(path, self.options.force_lib, long=self.options.long, optional=optionals, randorder=self.options.randorder, useabspath=self.options.abspath) for path in expand() ] def filter_sources(self): """ EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: dirname = os.path.join(SAGE_SRC, 'sage', 'doctest') sage: DD = DocTestDefaults(failed=True) sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: for i, source in enumerate(DC.sources): ... DC.stats[source.basename] = {'walltime': 0.1*(i+1)} sage: DC.stats['sage.doctest.control'] = {'failed':True,'walltime':1.0} sage: DC.filter_sources() Only doctesting files that failed last test. sage: len(DC.sources) 1 """ # Filter the sources to only include those with failing doctests if the --failed option is passed if self.options.failed: self.log("Only doctesting files that failed last test.") def is_failure(source): basename = source.basename return basename not in self.stats or self.stats[basename].get( 'failed') self.sources = filter(is_failure, self.sources) def sort_sources(self): """ This function sorts the sources so that slower doctests are run first. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: dirname = os.path.join(SAGE_SRC, 'sage', 'doctest') sage: DD = DocTestDefaults(nthreads=2) sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: DC.sources.sort(key=lambda s:s.basename) sage: for i, source in enumerate(DC.sources): ... DC.stats[source.basename] = {'walltime': 0.1*(i+1)} sage: DC.sort_sources() Sorting sources by runtime so that slower doctests are run first.... sage: print "\n".join([source.basename for source in DC.sources]) sage.doctest.util sage.doctest.test sage.doctest.sources sage.doctest.reporting sage.doctest.parsing sage.doctest.forker sage.doctest.control sage.doctest.all sage.doctest """ if self.options.nthreads > 1 and len( self.sources) > self.options.nthreads: self.log( "Sorting sources by runtime so that slower doctests are run first...." ) default = dict(walltime=0) def sort_key(source): basename = source.basename return -self.stats.get(basename, default).get('walltime'), basename self.sources = [ x[1] for x in sorted((sort_key(source), source) for source in self.sources) ] def run_doctests(self): """ Actually runs the doctests. This function is called by :meth:run(). EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: dirname = os.path.join(SAGE_SRC, 'sage', 'rings', 'homset.py') sage: DD = DocTestDefaults() sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: DC.run_doctests() Doctesting 1 file. sage -t .../sage/rings/homset.py [... tests, ... s] ---------------------------------------------------------------------- All tests passed! ---------------------------------------------------------------------- Total time for all tests: ... seconds cpu time: ... seconds cumulative wall time: ... seconds """ nfiles = 0 nother = 0 for F in self.sources: if isinstance(F, FileDocTestSource): nfiles += 1 else: nother += 1 if self.sources: filestr = ", ".join( ([count_noun(nfiles, "file")] if nfiles else []) + ([count_noun(nother, "other source")] if nother else [])) threads = " using %s threads" % ( self.options.nthreads) if self.options.nthreads > 1 else "" iterations = [] if self.options.global_iterations > 1: iterations.append("%s global iterations" % (self.options.global_iterations)) if self.options.file_iterations > 1: iterations.append("%s file iterations" % (self.options.file_iterations)) iterations = ", ".join(iterations) if iterations: iterations = " (%s)" % (iterations) self.log("Doctesting %s%s%s." % (filestr, threads, iterations)) self.reporter = DocTestReporter(self) self.dispatcher = DocTestDispatcher(self) N = self.options.global_iterations for it in range(N): try: self.timer = Timer().start() self.dispatcher.dispatch() except KeyboardInterrupt: it = N - 1 break finally: self.timer.stop() self.reporter.finalize() self.cleanup(it == N - 1) else: self.log("No files to doctest") self.reporter = DictAsObject(dict(error_status=0)) def cleanup(self, final=True): """ Runs cleanup activities after actually running doctests. In particular, saves the stats to disk and closes the logfile. INPUT: - ``final`` -- whether to close the logfile EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: dirname = os.path.join(SAGE_SRC, 'sage', 'rings', 'infinity.py') sage: DD = DocTestDefaults() sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: DC.sources.sort(key=lambda s:s.basename) sage: for i, source in enumerate(DC.sources): ....: DC.stats[source.basename] = {'walltime': 0.1*(i+1)} ....: sage: DC.run() Running doctests with ID ... Doctesting 1 file. sage -t .../rings/infinity.py [... tests, ... s] ---------------------------------------------------------------------- All tests passed! ---------------------------------------------------------------------- Total time for all tests: ... seconds cpu time: ... seconds cumulative wall time: ... seconds 0 sage: DC.cleanup() """ self.stats.update(self.reporter.stats) self.save_stats(self.options.stats_path) # Close the logfile if final and self.logfile is not None: self.logfile.close() self.logfile = None def _assemble_cmd(self): """ Assembles a shell command used in running tests under gdb or valgrind. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(timeout=123), ["hello_world.py"]) sage: print DC._assemble_cmd() python "$SAGE_LOCAL/bin/sage-runtests" --serial --timeout=123 hello_world.py """ cmd = '''python "%s" --serial ''' % (os.path.join( "$SAGE_LOCAL", "bin", "sage-runtests")) opt = dict_difference(self.options.__dict__, DocTestDefaults().__dict__) for o in ("all", "sagenb"): if o in opt: raise ValueError( "You cannot run gdb/valgrind on the whole sage%s library" % ("" if o == "all" else "nb")) for o in ("all", "sagenb", "long", "force_lib", "verbose", "failed", "new"): if o in opt: cmd += "--%s " % o for o in ("timeout", "optional", "randorder", "stats_path"): if o in opt: cmd += "--%s=%s " % (o, opt[o]) return cmd + " ".join(self.files) def run_val_gdb(self, testing=False): """ Spawns a subprocess to run tests under the control of gdb or valgrind. INPUT: - ``testing`` -- boolean; if True then the command to be run will be printed rather than a subprocess started. EXAMPLES: Note that the command lines include unexpanded environment variables. It is safer to let the shell expand them than to expand them here and risk insufficient quoting. :: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DD = DocTestDefaults(gdb=True) sage: DC = DocTestController(DD, ["hello_world.py"]) sage: DC.run_val_gdb(testing=True) exec gdb -x "$SAGE_LOCAL/bin/sage-gdb-commands" --args python "$SAGE_LOCAL/bin/sage-runtests" --serial --timeout=0 hello_world.py :: sage: DD = DocTestDefaults(valgrind=True, optional="all", timeout=172800) sage: DC = DocTestController(DD, ["hello_world.py"]) sage: DC.run_val_gdb(testing=True) exec valgrind --tool=memcheck --leak-resolution=high --leak-check=full --num-callers=25 --suppressions="$SAGE_LOCAL/lib/valgrind/sage.supp" --log-file=".../valgrind/sage-memcheck.%p" python "$SAGE_LOCAL/bin/sage-runtests" --serial --timeout=172800 --optional=all hello_world.py """ try: sage_cmd = self._assemble_cmd() except ValueError: self.log(sys.exc_info()[1]) return 2 opt = self.options if opt.gdb: cmd = '''exec gdb -x "$SAGE_LOCAL/bin/sage-gdb-commands" --args ''' flags = "" if opt.logfile: sage_cmd += " --logfile %s" % (opt.logfile) else: if opt.logfile is None: default_log = os.path.join(DOT_SAGE, "valgrind") if os.path.exists(default_log): if not os.path.isdir(default_log): self.log("%s must be a directory" % default_log) return 2 else: os.makedirs(default_log) logfile = os.path.join(default_log, "sage-%s") else: logfile = opt.logfile if opt.valgrind: toolname = "memcheck" flags = os.getenv("SAGE_MEMCHECK_FLAGS") if flags is None: flags = "--leak-resolution=high --leak-check=full --num-callers=25 " flags += '''--suppressions="%s" ''' % (os.path.join( "$SAGE_LOCAL", "lib", "valgrind", "sage.supp")) elif opt.massif: toolname = "massif" flags = os.getenv("SAGE_MASSIF_FLAGS", "--depth=6 ") elif opt.cachegrind: toolname = "cachegrind" flags = os.getenv("SAGE_CACHEGRIND_FLAGS", "") elif opt.omega: toolname = "exp-omega" flags = os.getenv("SAGE_OMEGA_FLAGS", "") cmd = "exec valgrind --tool=%s " % (toolname) flags += ''' --log-file="%s" ''' % logfile if opt.omega: toolname = "omega" if "%s" in flags: flags %= toolname + ".%p" # replace %s with toolname cmd += flags + sage_cmd self.log(cmd) sys.stdout.flush() sys.stderr.flush() if self.logfile is not None: self.logfile.flush() if testing: return import signal, subprocess def handle_alrm(sig, frame): raise RuntimeError signal.signal(signal.SIGALRM, handle_alrm) p = subprocess.Popen(cmd, shell=True) if opt.timeout > 0: signal.alarm(opt.timeout) try: return p.wait() except RuntimeError: self.log(" Time out") return 4 except KeyboardInterrupt: self.log(" Interrupted") return 128 finally: signal.signal(signal.SIGALRM, signal.SIG_IGN) if p.returncode is None: p.terminate() def run(self): """ This function is called after initialization to set up and run all doctests. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: DD = DocTestDefaults() sage: filename = os.path.join(SAGE_SRC, "sage", "sets", "non_negative_integers.py") sage: DC = DocTestController(DD, [filename]) sage: DC.run() Running doctests with ID ... Doctesting 1 file. sage -t .../sage/sets/non_negative_integers.py [... tests, ... s] ---------------------------------------------------------------------- All tests passed! ---------------------------------------------------------------------- Total time for all tests: ... seconds cpu time: ... seconds cumulative wall time: ... seconds 0 """ opt = self.options L = (opt.gdb, opt.valgrind, opt.massif, opt.cachegrind, opt.omega) if any(L): if L.count(True) > 1: self.log( "You may only specify one of gdb, valgrind/memcheck, massif, cachegrind, omega" ) return 2 return self.run_val_gdb() else: self.test_safe_directory() self.create_run_id() self.add_files() self.expand_files_into_sources() self.filter_sources() self.sort_sources() self.run_doctests() return self.reporter.error_status
class DocTestController(SageObject): """ This class controls doctesting of files. After creating it with appropriate options, call the :meth:`run` method to run the doctests. """ def __init__(self, options, args): """ Initialization. INPUT: - options -- either options generated from the command line by SAGE_LOCAL/bin/sage-runtests or a DocTestDefaults object (possibly with some entries modified) - args -- a list of filenames to doctest EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC DocTest Controller """ # First we modify options to take environment variables into # account and check compatibility of the user's specified # options. if options.timeout < 0: if options.gdb or options.debug: # Interactive debuggers: "infinite" timeout options.timeout = 0 elif options.valgrind or options.massif or options.cachegrind or options.omega: # Non-interactive debuggers: 48 hours options.timeout = int(os.getenv('SAGE_TIMEOUT_VALGRIND', 48 * 60 * 60)) elif options.long: options.timeout = int(os.getenv('SAGE_TIMEOUT_LONG', 30 * 60)) else: options.timeout = int(os.getenv('SAGE_TIMEOUT', 5 * 60)) if options.nthreads == 0: options.nthreads = int(os.getenv('SAGE_NUM_THREADS_PARALLEL',1)) if options.failed and not (args or options.new or options.sagenb): # If the user doesn't specify any files then we rerun all failed files. options.all = True if options.global_iterations == 0: options.global_iterations = int(os.environ.get('SAGE_TEST_GLOBAL_ITER', 1)) if options.file_iterations == 0: options.file_iterations = int(os.environ.get('SAGE_TEST_ITER', 1)) if options.debug and options.nthreads > 1: print("Debugging requires single-threaded operation, setting number of threads to 1.") options.nthreads = 1 if options.serial: options.nthreads = 1 if options.verbose: options.show_skipped = True if isinstance(options.optional, basestring): s = options.optional.lower() if s in ['all', 'true']: options.optional = True else: options.optional = set(s.split(',')) # Check that all tags are valid for o in options.optional: if not optionaltag_regex.search(o): raise ValueError('invalid optional tag %s'%repr(o)) self.options = options self.files = args if options.logfile: try: self.logfile = open(options.logfile, 'a') except IOError: print "Unable to open logfile at %s\nProceeding without logging."%(options.logfile) self.logfile = None else: self.logfile = None self.stats = {} self.load_stats(options.stats_path) self._init_warn_long() def _init_warn_long(self): """ Pick a suitable default for the ``--warn-long`` option if not specified. It is desirable to have all tests (even ``# long`` ones) finish in less than about 5 seconds. Longer tests typically don't add coverage, they just make testing slow. The default used here is 60 seconds on a modern computer. It should eventually be lowered to 5 seconds, but its best to boil the frog slowly. The stored timings are used to adjust this limit according to the machine running the tests. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC.options.warn_long = 5.0 sage: DC._init_warn_long() sage: DC.options.warn_long # existing command-line options are not changed 5.00000000000000 """ if self.options.warn_long is not None: # Specified on the command line return try: self.options.warn_long = 60.0 * self.second_on_modern_computer() except RuntimeError as err: if not sage.doctest.DOCTEST_MODE: print(err) # No usable timing information def second_on_modern_computer(self): """ Return the wall time equivalent of a second on a modern computer. OUTPUT: Float. The wall time on your computer that would be equivalent to one second on a modern computer. Unless you have kick-ass hardware this should always be >= 1.0. Raises a ``RuntimeError`` if there are no stored timings to use as benchmark. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC.second_on_modern_computer() # not tested """ if len(self.stats) == 0: raise RuntimeError('no stored timings available') success = [] failed = [] for mod in self.stats.values(): if mod.get('failed', False): failed.append(mod['walltime']) else: success.append(mod['walltime']) if len(success) < 2500: raise RuntimeError('too few successful tests, not using stored timings') if len(failed) > 20: raise RuntimeError('too many failed tests, not using stored timings') expected = 12800.0 # Core i7 Quad-Core 2014 return sum(success) / expected def _repr_(self): """ String representation. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: repr(DC) # indirect doctest 'DocTest Controller' """ return "DocTest Controller" def load_stats(self, filename): """ Load stats from the most recent run(s). Stats are stored as a JSON file, and include information on which files failed tests and the walltime used for execution of the doctests. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: import json sage: filename = tmp_filename() sage: with open(filename, 'w') as stats_file: ... json.dump({'sage.doctest.control':{u'walltime':1.0r}}, stats_file) sage: DC.load_stats(filename) sage: DC.stats['sage.doctest.control'] {u'walltime': 1.0} If the file doesn't exist, nothing happens. If there is an error, print a message. In any case, leave the stats alone:: sage: d = tmp_dir() sage: DC.load_stats(os.path.join(d)) # Cannot read a directory Error loading stats from ... sage: DC.load_stats(os.path.join(d, "no_such_file")) sage: DC.stats['sage.doctest.control'] {u'walltime': 1.0} """ # Simply ignore non-existing files if not os.path.exists(filename): return try: with open(filename) as stats_file: self.stats.update(json.load(stats_file)) except Exception: self.log("Error loading stats from %s"%filename) def save_stats(self, filename): """ Save stats from the most recent run as a JSON file. WARNING: This function overwrites the file. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC.stats['sage.doctest.control'] = {u'walltime':1.0r} sage: filename = tmp_filename() sage: DC.save_stats(filename) sage: import json sage: D = json.load(open(filename)) sage: D['sage.doctest.control'] {u'walltime': 1.0} """ from sage.misc.temporary_file import atomic_write with atomic_write(filename) as stats_file: json.dump(self.stats, stats_file) def log(self, s, end="\n"): """ Logs the string ``s + end`` (where ``end`` is a newline by default) to the logfile and prints it to the standard output. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DD = DocTestDefaults(logfile=tmp_filename()) sage: DC = DocTestController(DD, []) sage: DC.log("hello world") hello world sage: DC.logfile.close() sage: print open(DD.logfile).read() hello world Check that no duplicate logs appear, even when forking (:trac:`15244`):: sage: DD = DocTestDefaults(logfile=tmp_filename()) sage: DC = DocTestController(DD, []) sage: DC.log("hello world") hello world sage: if os.fork() == 0: ....: DC.logfile.close() ....: os._exit(0) sage: DC.logfile.close() sage: print open(DD.logfile).read() hello world """ s += end if self.logfile is not None: self.logfile.write(s) self.logfile.flush() sys.stdout.write(s) sys.stdout.flush() def test_safe_directory(self, dir=None): """ Test that the given directory is safe to run Python code from. We use the check added to Python for this, which gives a warning when the current directory is considered unsafe. We promote this warning to an error with ``-Werror``. See ``sage/tests/cmdline.py`` for a doctest that this works, see also :trac:`13579`. TESTS:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DD = DocTestDefaults() sage: DC = DocTestController(DD, []) sage: DC.test_safe_directory() sage: d = os.path.join(tmp_dir(), "test") sage: os.mkdir(d) sage: os.chmod(d, 0o777) sage: DC.test_safe_directory(d) Traceback (most recent call last): ... RuntimeError: refusing to run doctests... """ import subprocess with open(os.devnull, 'w') as dev_null: if subprocess.call(['python', '-Werror', '-c', ''], stdout=dev_null, stderr=dev_null, cwd=dir) != 0: raise RuntimeError( "refusing to run doctests from the current " "directory '{}' since untrusted users could put files in " "this directory, making it unsafe to run Sage code from" .format(os.getcwd())) def create_run_id(self): """ Creates the run id. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC.create_run_id() Running doctests with ID ... """ self.run_id = time.strftime('%Y-%m-%d-%H-%M-%S-') + "%08x" % random.getrandbits(32) from sage.version import version self.log("Running doctests with ID %s."%self.run_id) def add_files(self): r""" Checks for the flags '--all', '--new' and '--sagenb'. For each one present, this function adds the appropriate directories and files to the todo list. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: log_location = os.path.join(SAGE_TMP, 'control_dt_log.log') sage: DD = DocTestDefaults(all=True, logfile=log_location) sage: DC = DocTestController(DD, []) sage: DC.add_files() Doctesting entire Sage library. sage: os.path.join(SAGE_SRC, 'sage') in DC.files True :: sage: DD = DocTestDefaults(new = True) sage: DC = DocTestController(DD, []) sage: DC.add_files() Doctesting ... :: sage: DD = DocTestDefaults(sagenb = True) sage: DC = DocTestController(DD, []) sage: DC.add_files() Doctesting the Sage notebook. sage: DC.files[0][-6:] 'sagenb' """ opj = os.path.join from sage.env import SAGE_SRC, SAGE_ROOT def all_files(): from glob import glob self.files.append(opj(SAGE_SRC, 'sage')) self.files.append(opj(SAGE_SRC, 'sage_setup')) self.files.append(opj(SAGE_SRC, 'doc', 'common')) self.files.extend(glob(opj(SAGE_SRC, 'doc', '[a-z][a-z]'))) self.options.sagenb = True DOT_GIT= opj(SAGE_ROOT, '.git') have_git = os.path.exists(DOT_GIT) if self.options.all or (self.options.new and not have_git): self.log("Doctesting entire Sage library.") all_files() elif self.options.new and have_git: # Get all files changed in the working repo. self.log("Doctesting files changed since last git commit") import subprocess change = subprocess.check_output(["git", "--git-dir=" + DOT_GIT, "--work-tree=" + SAGE_ROOT, "status", "--porcelain"]) for line in change.split("\n"): if not line: continue data = line.strip().split(' ') status, filename = data[0], data[-1] if (set(status).issubset("MARCU") and filename.startswith("src/sage") and (filename.endswith(".py") or filename.endswith(".pyx"))): self.files.append(os.path.relpath(opj(SAGE_ROOT,filename))) if self.options.sagenb: if not self.options.all: self.log("Doctesting the Sage notebook.") from pkg_resources import Requirement, working_set sagenb_loc = working_set.find(Requirement.parse('sagenb')).location self.files.append(opj(sagenb_loc, 'sagenb')) def expand_files_into_sources(self): r""" Expands ``self.files``, which may include directories, into a list of :class:`sage.doctest.FileDocTestSource` This function also handles the optional command line option. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: dirname = os.path.join(SAGE_SRC, 'sage', 'doctest') sage: DD = DocTestDefaults(optional='all') sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: len(DC.sources) 10 sage: DC.sources[0].options.optional True :: sage: DD = DocTestDefaults(optional='magma,guava') sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: sorted(list(DC.sources[0].options.optional)) ['guava', 'magma'] We check that files are skipped appropriately:: sage: dirname = tmp_dir() sage: filename = os.path.join(dirname, 'not_tested.py') sage: with open(filename, 'w') as F: ....: F.write("#"*80 + "\n\n\n\n## nodoctest\n sage: 1+1\n 4") sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: DC.sources [] The directory ``sage/doctest/tests`` contains ``nodoctest.py`` but the files should still be tested when that directory is explicitly given (as opposed to being recursed into):: sage: DC = DocTestController(DD, [os.path.join(SAGE_SRC, 'sage', 'doctest', 'tests')]) sage: DC.expand_files_into_sources() sage: len(DC.sources) >= 10 True """ def expand(): for path in self.files: if os.path.isdir(path): for root, dirs, files in os.walk(path): for dir in list(dirs): if dir[0] == "." or skipdir(os.path.join(root,dir)): dirs.remove(dir) for file in files: if not skipfile(os.path.join(root,file)): yield os.path.join(root, file) else: # the user input this file explicitly, so we don't skip it yield path self.sources = [FileDocTestSource(path, self.options) for path in expand()] def filter_sources(self): """ EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: dirname = os.path.join(SAGE_SRC, 'sage', 'doctest') sage: DD = DocTestDefaults(failed=True) sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: for i, source in enumerate(DC.sources): ... DC.stats[source.basename] = {'walltime': 0.1*(i+1)} sage: DC.stats['sage.doctest.control'] = {'failed':True,'walltime':1.0} sage: DC.filter_sources() Only doctesting files that failed last test. sage: len(DC.sources) 1 """ # Filter the sources to only include those with failing doctests if the --failed option is passed if self.options.failed: self.log("Only doctesting files that failed last test.") def is_failure(source): basename = source.basename return basename not in self.stats or self.stats[basename].get('failed') self.sources = [x for x in self.sources if is_failure(x)] def sort_sources(self): r""" This function sorts the sources so that slower doctests are run first. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: dirname = os.path.join(SAGE_SRC, 'sage', 'doctest') sage: DD = DocTestDefaults(nthreads=2) sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: DC.sources.sort(key=lambda s:s.basename) sage: for i, source in enumerate(DC.sources): ... DC.stats[source.basename] = {'walltime': 0.1*(i+1)} sage: DC.sort_sources() Sorting sources by runtime so that slower doctests are run first.... sage: print "\n".join([source.basename for source in DC.sources]) sage.doctest.util sage.doctest.test sage.doctest.sources sage.doctest.reporting sage.doctest.parsing sage.doctest.forker sage.doctest.fixtures sage.doctest.control sage.doctest.all sage.doctest """ if self.options.nthreads > 1 and len(self.sources) > self.options.nthreads: self.log("Sorting sources by runtime so that slower doctests are run first....") default = dict(walltime=0) def sort_key(source): basename = source.basename return -self.stats.get(basename, default).get('walltime'), basename self.sources = [x[1] for x in sorted((sort_key(source), source) for source in self.sources)] def run_doctests(self): """ Actually runs the doctests. This function is called by :meth:`run`. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: dirname = os.path.join(SAGE_SRC, 'sage', 'rings', 'homset.py') sage: DD = DocTestDefaults() sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: DC.run_doctests() Doctesting 1 file. sage -t .../sage/rings/homset.py [... tests, ... s] ---------------------------------------------------------------------- All tests passed! ---------------------------------------------------------------------- Total time for all tests: ... seconds cpu time: ... seconds cumulative wall time: ... seconds """ nfiles = 0 nother = 0 for F in self.sources: if isinstance(F, FileDocTestSource): nfiles += 1 else: nother += 1 if self.sources: filestr = ", ".join(([count_noun(nfiles, "file")] if nfiles else []) + ([count_noun(nother, "other source")] if nother else [])) threads = " using %s threads"%(self.options.nthreads) if self.options.nthreads > 1 else "" iterations = [] if self.options.global_iterations > 1: iterations.append("%s global iterations"%(self.options.global_iterations)) if self.options.file_iterations > 1: iterations.append("%s file iterations"%(self.options.file_iterations)) iterations = ", ".join(iterations) if iterations: iterations = " (%s)"%(iterations) self.log("Doctesting %s%s%s."%(filestr, threads, iterations)) self.reporter = DocTestReporter(self) self.dispatcher = DocTestDispatcher(self) N = self.options.global_iterations for it in range(N): try: self.timer = Timer().start() self.dispatcher.dispatch() except KeyboardInterrupt: it = N - 1 break finally: self.timer.stop() self.reporter.finalize() self.cleanup(it == N - 1) else: self.log("No files to doctest") self.reporter = DictAsObject(dict(error_status=0)) def cleanup(self, final=True): """ Runs cleanup activities after actually running doctests. In particular, saves the stats to disk and closes the logfile. INPUT: - ``final`` -- whether to close the logfile EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: dirname = os.path.join(SAGE_SRC, 'sage', 'rings', 'infinity.py') sage: DD = DocTestDefaults() sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: DC.sources.sort(key=lambda s:s.basename) sage: for i, source in enumerate(DC.sources): ....: DC.stats[source.basename] = {'walltime': 0.1*(i+1)} ....: sage: DC.run() Running doctests with ID ... Doctesting 1 file. sage -t .../rings/infinity.py [... tests, ... s] ---------------------------------------------------------------------- All tests passed! ---------------------------------------------------------------------- Total time for all tests: ... seconds cpu time: ... seconds cumulative wall time: ... seconds 0 sage: DC.cleanup() """ self.stats.update(self.reporter.stats) self.save_stats(self.options.stats_path) # Close the logfile if final and self.logfile is not None: self.logfile.close() self.logfile = None def _assemble_cmd(self): """ Assembles a shell command used in running tests under gdb or valgrind. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(timeout=123), ["hello_world.py"]) sage: print DC._assemble_cmd() python "$SAGE_LOCAL/bin/sage-runtests" --serial --timeout=123 hello_world.py """ cmd = '''python "%s" --serial '''%(os.path.join("$SAGE_LOCAL","bin","sage-runtests")) opt = dict_difference(self.options.__dict__, DocTestDefaults().__dict__) for o in ("all", "sagenb"): if o in opt: raise ValueError("You cannot run gdb/valgrind on the whole sage%s library"%("" if o == "all" else "nb")) for o in ("all", "sagenb", "long", "force_lib", "verbose", "failed", "new"): if o in opt: cmd += "--%s "%o for o in ("timeout", "optional", "randorder", "stats_path"): if o in opt: cmd += "--%s=%s "%(o, opt[o]) return cmd + " ".join(self.files) def run_val_gdb(self, testing=False): """ Spawns a subprocess to run tests under the control of gdb or valgrind. INPUT: - ``testing`` -- boolean; if True then the command to be run will be printed rather than a subprocess started. EXAMPLES: Note that the command lines include unexpanded environment variables. It is safer to let the shell expand them than to expand them here and risk insufficient quoting. :: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DD = DocTestDefaults(gdb=True) sage: DC = DocTestController(DD, ["hello_world.py"]) sage: DC.run_val_gdb(testing=True) exec gdb -x "$SAGE_LOCAL/bin/sage-gdb-commands" --args python "$SAGE_LOCAL/bin/sage-runtests" --serial --timeout=0 hello_world.py :: sage: DD = DocTestDefaults(valgrind=True, optional="all", timeout=172800) sage: DC = DocTestController(DD, ["hello_world.py"]) sage: DC.run_val_gdb(testing=True) exec valgrind --tool=memcheck --leak-resolution=high --leak-check=full --num-callers=25 --suppressions="$SAGE_LOCAL/lib/valgrind/sage.supp" --log-file=".../valgrind/sage-memcheck.%p" python "$SAGE_LOCAL/bin/sage-runtests" --serial --timeout=172800 --optional=True hello_world.py """ try: sage_cmd = self._assemble_cmd() except ValueError: self.log(sys.exc_info()[1]) return 2 opt = self.options if opt.gdb: cmd = '''exec gdb -x "$SAGE_LOCAL/bin/sage-gdb-commands" --args ''' flags = "" if opt.logfile: sage_cmd += " --logfile %s"%(opt.logfile) else: if opt.logfile is None: default_log = os.path.join(DOT_SAGE, "valgrind") if os.path.exists(default_log): if not os.path.isdir(default_log): self.log("%s must be a directory"%default_log) return 2 else: os.makedirs(default_log) logfile = os.path.join(default_log, "sage-%s") else: logfile = opt.logfile if opt.valgrind: toolname = "memcheck" flags = os.getenv("SAGE_MEMCHECK_FLAGS") if flags is None: flags = "--leak-resolution=high --leak-check=full --num-callers=25 " flags += '''--suppressions="%s" '''%(os.path.join("$SAGE_LOCAL","lib","valgrind","sage.supp")) elif opt.massif: toolname = "massif" flags = os.getenv("SAGE_MASSIF_FLAGS", "--depth=6 ") elif opt.cachegrind: toolname = "cachegrind" flags = os.getenv("SAGE_CACHEGRIND_FLAGS", "") elif opt.omega: toolname = "exp-omega" flags = os.getenv("SAGE_OMEGA_FLAGS", "") cmd = "exec valgrind --tool=%s "%(toolname) flags += ''' --log-file="%s" ''' % logfile if opt.omega: toolname = "omega" if "%s" in flags: flags %= toolname + ".%p" # replace %s with toolname cmd += flags + sage_cmd self.log(cmd) sys.stdout.flush() sys.stderr.flush() if self.logfile is not None: self.logfile.flush() if testing: return # Setup Sage signal handler init_interrupts() import signal, subprocess p = subprocess.Popen(cmd, shell=True) if opt.timeout > 0: signal.alarm(opt.timeout) try: return p.wait() except AlarmInterrupt: self.log(" Timed out") return 4 except KeyboardInterrupt: self.log(" Interrupted") return 128 finally: signal.alarm(0) if p.returncode is None: p.terminate() def run(self): """ This function is called after initialization to set up and run all doctests. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: from sage.env import SAGE_SRC sage: import os sage: DD = DocTestDefaults() sage: filename = os.path.join(SAGE_SRC, "sage", "sets", "non_negative_integers.py") sage: DC = DocTestController(DD, [filename]) sage: DC.run() Running doctests with ID ... Doctesting 1 file. sage -t .../sage/sets/non_negative_integers.py [... tests, ... s] ---------------------------------------------------------------------- All tests passed! ---------------------------------------------------------------------- Total time for all tests: ... seconds cpu time: ... seconds cumulative wall time: ... seconds 0 """ opt = self.options L = (opt.gdb, opt.valgrind, opt.massif, opt.cachegrind, opt.omega) if any(L): if L.count(True) > 1: self.log("You may only specify one of gdb, valgrind/memcheck, massif, cachegrind, omega") return 2 return self.run_val_gdb() else: self.test_safe_directory() self.create_run_id() from sage.env import SAGE_ROOT DOT_GIT= os.path.join(SAGE_ROOT, '.git') if os.path.isdir(DOT_GIT): import subprocess try: branch = subprocess.check_output(["git", "--git-dir=" + DOT_GIT, "rev-parse", "--abbrev-ref", "HEAD"]) self.log("Git branch: " + branch, end="") except subprocess.CalledProcessError: pass self.add_files() self.expand_files_into_sources() self.filter_sources() self.sort_sources() self.run_doctests() return self.reporter.error_status
class DocTestController(SageObject): """ This class controls doctesting of files. After creating it with appropriate options, call the :meth:run() method to run the doctests. """ def __init__(self, options, args): """ Initialization. INPUT: - options -- either options generated from the command line by SAGE_ROOT/local/bin/sage-runtests or a DocTestDefaults object (possibly with some entries modified) - args -- a list of filenames to doctest EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC DocTest Controller """ # First we modify options to take environment variables into # account and check compatibility of the user's specified # options. if options.timeout < 0: if options.gdb or options.debug: # Interactive debuggers: "infinite" timeout options.timeout = 0 elif options.valgrind or options.massif or options.cachegrind or options.omega: # Non-interactive debuggers: 48 hours options.timeout = int(os.getenv('SAGE_TIMEOUT_VALGRIND', 48 * 60 * 60)) elif options.long: options.timeout = int(os.getenv('SAGE_TIMEOUT_LONG', 30 * 60)) else: options.timeout = int(os.getenv('SAGE_TIMEOUT', 5 * 60)) if options.nthreads == 0: options.nthreads = int(os.getenv('SAGE_NUM_THREADS_PARALLEL',1)) if options.failed and not (args or options.new or options.sagenb): # If the user doesn't specify any files then we rerun all failed files. options.all = True if options.global_iterations == 0: options.global_iterations = int(os.environ.get('SAGE_TEST_GLOBAL_ITER', 1)) if options.file_iterations == 0: options.file_iterations = int(os.environ.get('SAGE_TEST_ITER', 1)) if options.debug and options.nthreads > 1: print("Debugging requires single-threaded operation, setting number of threads to 1.") options.nthreads = 1 if options.serial: options.nthreads = 1 self.options = options self.files = args if options.all and options.logfile is None: options.logfile = os.path.join(os.environ['SAGE_TESTDIR'], 'test.log') if options.logfile: try: self.logfile = open(options.logfile, 'a') except IOError: print "Unable to open logfile at %s\nProceeding without logging."%(options.logfile) self.logfile = None else: self.logfile = None self.stats = {} self.load_stats(options.stats_path) def _repr_(self): """ String representation. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: repr(DC) # indirect doctest 'DocTest Controller' """ return "DocTest Controller" def load_stats(self, filename): """ Load stats from the most recent run(s). Stats are stored as a JSON file, and include information on which files failed tests and the walltime used for execution of the doctests. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: import json sage: filename = tmp_filename() sage: with open(filename, 'w') as stats_file: ... json.dump({'sage.doctest.control':{u'walltime':1.0r}}, stats_file) sage: DC.load_stats(filename) sage: DC.stats['sage.doctest.control'] {u'walltime': 1.0} If the file doesn't exist, nothing happens. If there is an error, print a message. In any case, leave the stats alone:: sage: d = tmp_dir() sage: DC.load_stats(os.path.join(d)) # Cannot read a directory Error loading stats from ... sage: DC.load_stats(os.path.join(d, "no_such_file")) sage: DC.stats['sage.doctest.control'] {u'walltime': 1.0} """ # Simply ignore non-existing files if not os.path.exists(filename): return try: with open(filename) as stats_file: self.stats.update(json.load(stats_file)) except StandardError: self.log("Error loading stats from %s"%filename) def save_stats(self, filename): """ Save stats from the most recent run as a JSON file. WARNING: This function overwrites the file. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC.stats['sage.doctest.control'] = {u'walltime':1.0r} sage: filename = tmp_filename() sage: DC.save_stats(filename) sage: import json sage: D = json.load(open(filename)) sage: D['sage.doctest.control'] {u'walltime': 1.0} """ with open(filename, 'w') as stats_file: json.dump(self.stats, stats_file) def log(self, s, end="\n"): """ Logs the string ``s + end`` (where ``end`` is a newline by default) to the logfile and prints it to the standard output. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DD = DocTestDefaults(logfile=tmp_filename()) sage: DC = DocTestController(DD, []) sage: DC.log("hello world") hello world sage: DC.logfile.close() sage: with open(DD.logfile) as logger: print logger.read() hello world """ s += end if self.logfile is not None: self.logfile.write(s) sys.stdout.write(s) def test_safe_directory(self, dir=None): """ Test that the given directory is safe to run Python code from. We use the check added to Python for this, which gives a warning when the current directory is considered unsafe. We promote this warning to an error with ``-Werror``. See ``sage/tests/cmdline.py`` for a doctest that this works, see also :trac:`13579`. TESTS:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DD = DocTestDefaults() sage: DC = DocTestController(DD, []) sage: DC.test_safe_directory() sage: d = os.path.join(tmp_dir(), "test") sage: os.mkdir(d) sage: os.chmod(d, 0o777) sage: DC.test_safe_directory(d) Traceback (most recent call last): ... RuntimeError: refusing to run doctests... """ import subprocess with open(os.devnull, 'w') as dev_null: if subprocess.call(['python', '-Werror', '-c', ''], stdout=dev_null, stderr=dev_null, cwd=dir) != 0: raise RuntimeError( "refusing to run doctests from the current " "directory '{}' since untrusted users could put files in " "this directory, making it unsafe to run Sage code from" .format(os.getcwd())) def create_run_id(self): """ Creates the run id. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(), []) sage: DC.create_run_id() Running doctests with ID ... """ self.run_id = time.strftime('%Y-%m-%d-%H-%M-%S-') + "%08x" % random.getrandbits(32) from sage.version import version self.log("Running doctests with ID %s."%self.run_id) def add_files(self): """ Checks for the flags '--all', '--new' and '--sagenb'. For each one present, this function adds the appropriate directories and files to the todo list. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: import os sage: log_location = os.path.join(SAGE_TMP, 'control_dt_log.log') sage: DD = DocTestDefaults(all=True, logfile=log_location) sage: DC = DocTestController(DD, []) sage: DC.add_files() Doctesting entire Sage library. sage: os.path.join(os.environ['SAGE_ROOT'], 'devel', 'sage', 'sage') in DC.files True :: sage: DD = DocTestDefaults(new = True) sage: DC = DocTestController(DD, []) sage: DC.add_files() Doctesting files changed since last HG commit. sage: len(DC.files) == len([L for L in hg_sage('status', interactive=False, debug=False)[0].split('\n') if len(L.split()) ==2 and L.split()[0] in ['M','A']]) True :: sage: DD = DocTestDefaults(sagenb = True) sage: DC = DocTestController(DD, []) sage: DC.add_files() Doctesting the Sage notebook. sage: DC.files[0][-6:] 'sagenb' """ opj = os.path.join SAGE_ROOT = os.environ['SAGE_ROOT'] base = opj(SAGE_ROOT, 'devel', 'sage') if self.options.all: self.log("Doctesting entire Sage library.") from glob import glob self.files.append(opj(base, 'sage')) self.files.append(opj(base, 'doc', 'common')) self.files.extend(glob(opj(base, 'doc', '[a-z][a-z]'))) self.options.sagenb = True elif self.options.new: self.log("Doctesting files changed since last HG commit.") import sage.all_cmdline from sage.misc.hg import hg_sage for X in hg_sage('status', interactive=False, debug=False)[0].split('\n'): tup = X.split() if len(tup) != 2: continue c, filename = tup if c in ['M','A']: filename = opj(os.environ['SAGE_ROOT'], 'devel', 'sage', filename) self.files.append(filename) if self.options.sagenb: if not self.options.all: self.log("Doctesting the Sage notebook.") from pkg_resources import Requirement, working_set sagenb_loc = working_set.find(Requirement.parse('sagenb')).location self.files.append(opj(sagenb_loc, 'sagenb')) def expand_files_into_sources(self): """ Expands ``self.files``, which may include directories, into a list of :class:`sage.doctest.FileDocTestSource` This function also handles the optional command line option. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: import os sage: dirname = os.path.join(os.environ['SAGE_ROOT'], 'devel', 'sage', 'sage', 'doctest') sage: DD = DocTestDefaults(optional='all') sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: len(DC.sources) 9 sage: DC.sources[0].optional True :: sage: DD = DocTestDefaults(optional='magma,guava') sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: sorted(list(DC.sources[0].optional)) ['guava', 'magma'] """ def skipdir(dirname): if os.path.exists(os.path.join(dirname, "nodoctest.py")): return True # Workaround for https://github.com/sagemath/sagenb/pull/84 if dirname.endswith(os.path.join(os.sep, 'sagenb', 'data')): return True return False def skipfile(filename): base, ext = os.path.splitext(filename) if ext not in ('.py', '.pyx', '.pxi', '.sage', '.spyx', '.rst', '.tex'): return True with open(filename) as F: return 'nodoctest' in F.read(50) def expand(): for path in self.files: if os.path.isdir(path): for root, dirs, files in os.walk(path): for dir in list(dirs): if dir[0] == "." or skipdir(os.path.join(root,dir)): dirs.remove(dir) for file in files: if not skipfile(os.path.join(root,file)): yield os.path.join(root, file) else: # the user input this file explicitly, so we don't skip it yield path if self.options.optional == 'all': optionals = True else: optionals = set(self.options.optional.lower().split(',')) self.sources = [FileDocTestSource(path, self.options.force_lib, long=self.options.long, optional=optionals, randorder=self.options.randorder, useabspath=self.options.abspath) for path in expand()] def filter_sources(self): """ EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: import os sage: dirname = os.path.join(os.environ['SAGE_ROOT'], 'devel', 'sage', 'sage', 'doctest') sage: DD = DocTestDefaults(failed=True) sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: for i, source in enumerate(DC.sources): ... DC.stats[source.basename] = {'walltime': 0.1*(i+1)} sage: DC.stats['sage.doctest.control'] = {'failed':True,'walltime':1.0} sage: DC.filter_sources() Only doctesting files that failed last test. sage: len(DC.sources) 1 """ # Filter the sources to only include those with failing doctests if the --failed option is passed if self.options.failed: self.log("Only doctesting files that failed last test.") def is_failure(source): basename = source.basename return basename not in self.stats or self.stats[basename].get('failed') self.sources = filter(is_failure, self.sources) def sort_sources(self): """ This function sorts the sources so that slower doctests are run first. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: import os sage: dirname = os.path.join(os.environ['SAGE_ROOT'], 'devel', 'sage', 'sage', 'doctest') sage: DD = DocTestDefaults(nthreads=2) sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: DC.sources.sort(key=lambda s:s.basename) sage: for i, source in enumerate(DC.sources): ... DC.stats[source.basename] = {'walltime': 0.1*(i+1)} sage: DC.sort_sources() Sorting sources by runtime so that slower doctests are run first.... sage: print "\n".join([source.basename for source in DC.sources]) sage.doctest.util sage.doctest.test sage.doctest.sources sage.doctest.reporting sage.doctest.parsing sage.doctest.forker sage.doctest.control sage.doctest.all sage.doctest """ if self.options.nthreads > 1 and len(self.sources) > self.options.nthreads: self.log("Sorting sources by runtime so that slower doctests are run first....") default = dict(walltime=0) def sort_key(source): basename = source.basename return -self.stats.get(basename, default).get('walltime'), basename self.sources = [x[1] for x in sorted((sort_key(source), source) for source in self.sources)] def run_doctests(self): """ Actually runs the doctests. This function is called by :meth:run(). EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: import os sage: dirname = os.path.join(os.environ['SAGE_ROOT'], 'devel', 'sage', 'sage', 'rings', 'homset.py') sage: DD = DocTestDefaults() sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: DC.run_doctests() Doctesting 1 file. sage -t .../sage/rings/homset.py [... tests, ... s] ---------------------------------------------------------------------- All tests passed! ---------------------------------------------------------------------- Total time for all tests: ... seconds cpu time: ... seconds cumulative wall time: ... seconds """ nfiles = 0 nother = 0 for F in self.sources: if isinstance(F, FileDocTestSource): nfiles += 1 else: nother += 1 if self.sources: filestr = ", ".join(([count_noun(nfiles, "file")] if nfiles else []) + ([count_noun(nother, "other source")] if nother else [])) threads = " using %s threads"%(self.options.nthreads) if self.options.nthreads > 1 else "" iterations = [] if self.options.global_iterations > 1: iterations.append("%s global iterations"%(self.options.global_iterations)) if self.options.file_iterations > 1: iterations.append("%s file iterations"%(self.options.file_iterations)) iterations = ", ".join(iterations) if iterations: iterations = " (%s)"%(iterations) self.log("Doctesting %s%s%s."%(filestr, threads, iterations)) self.reporter = DocTestReporter(self) self.dispatcher = DocTestDispatcher(self) N = self.options.global_iterations for it in range(N): try: self.timer = Timer().start() self.dispatcher.dispatch() except KeyboardInterrupt: it = N - 1 break finally: self.timer.stop() self.reporter.finalize() self.cleanup(it == N - 1) else: self.log("No files to doctest") self.reporter = DictAsObject(dict(error_status=0)) def cleanup(self, final=True): """ Runs cleanup activities after actually running doctests. In particular, saves the stats to disk and closes the logfile. INPUT: - ``final`` -- whether to close the logfile EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: import os sage: dirname = os.path.join(os.environ['SAGE_ROOT'], 'devel', 'sage', 'sage', 'rings', 'infinity.py') sage: DD = DocTestDefaults() sage: DC = DocTestController(DD, [dirname]) sage: DC.expand_files_into_sources() sage: DC.sources.sort(key=lambda s:s.basename) sage: for i, source in enumerate(DC.sources): ....: DC.stats[source.basename] = {'walltime': 0.1*(i+1)} ....: sage: DC.run() Running doctests with ID ... Doctesting 1 file. sage -t .../rings/infinity.py [... tests, ... s] ---------------------------------------------------------------------- All tests passed! ---------------------------------------------------------------------- Total time for all tests: ... seconds cpu time: ... seconds cumulative wall time: ... seconds 0 sage: DC.cleanup() """ self.stats.update(self.reporter.stats) self.save_stats(self.options.stats_path) # Close the logfile if final and self.logfile is not None: self.logfile.close() self.logfile = None def _assemble_cmd(self): """ Assembles a shell command used in running tests under gdb or valgrind. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DC = DocTestController(DocTestDefaults(timeout=123), ["hello_world.py"]) sage: print DC._assemble_cmd() python "$SAGE_LOCAL/bin/sage-runtests" --serial --timeout=123 hello_world.py """ cmd = '''python "%s" --serial '''%(os.path.join("$SAGE_LOCAL","bin","sage-runtests")) opt = dict_difference(self.options.__dict__, DocTestDefaults().__dict__) for o in ("all", "sagenb"): if o in opt: raise ValueError("You cannot run gdb/valgrind on the whole sage%s library"%("" if o == "all" else "nb")) for o in ("all", "sagenb", "long", "force_lib", "verbose", "failed", "new"): if o in opt: cmd += "--%s "%o for o in ("timeout", "optional", "randorder", "stats_path"): if o in opt: cmd += "--%s=%s "%(o, opt[o]) return cmd + " ".join(self.files) def run_val_gdb(self, testing=False): """ Spawns a subprocess to run tests under the control of gdb or valgrind. INPUT: - ``testing`` -- boolean; if True then the command to be run will be printed rather than a subprocess started. EXAMPLES: Note that the command lines include unexpanded environment variables. It is safer to let the shell expand them than to expand them here and risk insufficient quoting. :: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: DD = DocTestDefaults(gdb=True) sage: DC = DocTestController(DD, ["hello_world.py"]) sage: DC.run_val_gdb(testing=True) exec gdb -x "$SAGE_LOCAL/bin/sage-gdb-commands" --args python "$SAGE_LOCAL/bin/sage-runtests" --serial --timeout=0 hello_world.py :: sage: DD = DocTestDefaults(valgrind=True, optional="all", timeout=172800) sage: DC = DocTestController(DD, ["hello_world.py"]) sage: DC.run_val_gdb(testing=True) exec valgrind --tool=memcheck --leak-resolution=high --leak-check=full --num-callers=25 --suppressions="$SAGE_LOCAL/lib/valgrind/sage.supp" --log-file=".../valgrind/sage-memcheck.%p" python "$SAGE_LOCAL/bin/sage-runtests" --serial --timeout=172800 --optional=all hello_world.py """ try: sage_cmd = self._assemble_cmd() except ValueError: self.log(sys.exc_info()[1]) return 2 opt = self.options if opt.gdb: cmd = '''exec gdb -x "$SAGE_LOCAL/bin/sage-gdb-commands" --args ''' flags = "" if opt.logfile: sage_cmd += " --logfile %s"%(opt.logfile) else: if opt.logfile is None: default_log = os.path.join(DOT_SAGE, "valgrind") if os.path.exists(default_log): if not os.path.isdir(default_log): self.log("%s must be a directory"%default_log) return 2 else: os.makedirs(default_log) logfile = os.path.join(default_log, "sage-%s") else: logfile = opt.logfile if opt.valgrind: toolname = "memcheck" flags = os.getenv("SAGE_MEMCHECK_FLAGS") if flags is None: flags = "--leak-resolution=high --leak-check=full --num-callers=25 " flags += '''--suppressions="%s" '''%(os.path.join("$SAGE_LOCAL","lib","valgrind","sage.supp")) elif opt.massif: toolname = "massif" flags = os.getenv("SAGE_MASSIF_FLAGS", "--depth=6 ") elif opt.cachegrind: toolname = "cachegrind" flags = os.getenv("SAGE_CACHEGRIND_FLAGS", "") elif opt.omega: toolname = "exp-omega" flags = os.getenv("SAGE_OMEGA_FLAGS", "") cmd = "exec valgrind --tool=%s "%(toolname) flags += ''' --log-file="%s" ''' % logfile if opt.omega: toolname = "omega" if "%s" in flags: flags %= toolname + ".%p" # replace %s with toolname cmd += flags + sage_cmd self.log(cmd) sys.stdout.flush() sys.stderr.flush() if self.logfile is not None: self.logfile.flush() if testing: return import signal, subprocess def handle_alrm(sig, frame): raise RuntimeError signal.signal(signal.SIGALRM, handle_alrm) p = subprocess.Popen(cmd, shell=True) if opt.timeout > 0: signal.alarm(opt.timeout) try: return p.wait() except RuntimeError: self.log(" Time out") return 4 except KeyboardInterrupt: self.log(" Interrupted") return 128 finally: signal.signal(signal.SIGALRM, signal.SIG_IGN) if p.returncode is None: p.terminate() def run(self): """ This function is called after initialization to set up and run all doctests. EXAMPLES:: sage: from sage.doctest.control import DocTestDefaults, DocTestController sage: import os sage: DD = DocTestDefaults() sage: filename = os.path.join(os.environ["SAGE_ROOT"], "devel", "sage", "sage", "sets", "non_negative_integers.py") sage: DC = DocTestController(DD, [filename]) sage: DC.run() Running doctests with ID ... Doctesting 1 file. sage -t .../sage/sets/non_negative_integers.py [... tests, ... s] ---------------------------------------------------------------------- All tests passed! ---------------------------------------------------------------------- Total time for all tests: ... seconds cpu time: ... seconds cumulative wall time: ... seconds 0 """ opt = self.options L = (opt.gdb, opt.valgrind, opt.massif, opt.cachegrind, opt.omega) if any(L): if L.count(True) > 1: self.log("You may only specify one of gdb, valgrind/memcheck, massif, cachegrind, omega") return 2 return self.run_val_gdb() else: self.test_safe_directory() self.create_run_id() self.add_files() self.expand_files_into_sources() self.filter_sources() self.sort_sources() self.run_doctests() return self.reporter.error_status