diff --git a/IPython/sphinxext/custom_doctests.py b/IPython/sphinxext/custom_doctests.py new file mode 100644 index 0000000..7678fd6 --- /dev/null +++ b/IPython/sphinxext/custom_doctests.py @@ -0,0 +1,155 @@ +""" +Handlers for IPythonDirective's @doctest pseudo-decorator. + +The Sphinx extension that provides support for embedded IPython code provides +a pseudo-decorator @doctest, which treats the input/output block as a +doctest, raising a RuntimeError during doc generation if the actual output +(after running the input) does not match the expected output. + +An example usage is: + +.. code-block:: rst + + .. ipython:: + + In [1]: x = 1 + + @doctest + In [2]: x + 2 + Out[3]: 3 + +One can also provide arguments to the decorator. The first argument should be +the name of a custom handler. The specification of any other arguments is +determined by the handler. For example, + +.. code-block:: rst + + .. ipython:: + + @doctest float + In [154]: 0.1 + 0.2 + Out[154]: 0.3 + +allows the actual output ``0.30000000000000004`` to match the expected output +due to a comparison with `np.allclose`. + +This module contains handlers for the @doctest pseudo-decorator. Handlers +should have the following function signature:: + + handler(sphinx_shell, args, input_lines, found, submitted) + +where `sphinx_shell` is the embedded Sphinx shell, `args` contains the list +of arguments that follow: '@doctest handler_name', `input_lines` contains +a list of the lines relevant to the current doctest, `found` is a string +containing the output from the IPython shell, and `submitted` is a string +containing the expected output from the IPython shell. + +Handlers must be registered in the `doctests` dict at the end of this module. + +""" + +def str_to_array(s): + """ + Simplistic converter of strings from repr to float NumPy arrays. + + If the repr representation has ellipsis in it, then this will fail. + + Parameters + ---------- + s : str + The repr version of a NumPy array. + + Examples + -------- + >>> s = "array([ 0.3, inf, nan])" + >>> a = str_to_array(s) + + """ + import numpy as np + + # Need to make sure eval() knows about inf and nan. + # This also assumes default printoptions for NumPy. + from numpy import inf, nan + + if s.startswith(u'array'): + # Remove array( and ) + s = s[6:-1] + + if s.startswith(u'['): + a = np.array(eval(s), dtype=float) + else: + # Assume its a regular float. Force 1D so we can index into it. + a = np.atleast_1d(float(s)) + return a + +def float_doctest(sphinx_shell, args, input_lines, found, submitted): + """ + Doctest which allow the submitted output to vary slightly from the input. + + Here is how it might appear in an rst file: + + .. code-block:: rst + + .. ipython:: + + @doctest float + In [1]: 0.1 + 0.2 + Out[1]: 0.3 + + """ + import numpy as np + + if len(args) == 2: + rtol = 1e-05 + atol = 1e-08 + else: + # Both must be specified if any are specified. + try: + rtol = float(args[2]) + atol = float(args[3]) + except IndexError: + e = ("Both `rtol` and `atol` must be specified " + "if either are specified: {0}".format(args)) + raise IndexError(e) + + try: + submitted = str_to_array(submitted) + found = str_to_array(found) + except: + # For example, if the array is huge and there are ellipsis in it. + error = True + else: + found_isnan = np.isnan(found) + submitted_isnan = np.isnan(submitted) + error = not np.allclose(found_isnan, submitted_isnan) + error |= not np.allclose(found[~found_isnan], + submitted[~submitted_isnan], + rtol=rtol, atol=atol) + + TAB = ' ' * 4 + directive = sphinx_shell.directive + if directive is None: + source = 'Unavailable' + content = 'Unavailable' + else: + source = directive.state.document.current_source + # Add tabs and make into a single string. + content = '\n'.join([TAB + line for line in directive.content]) + + if error: + + e = ('doctest float comparison failure\n\n' + 'Document source: {0}\n\n' + 'Raw content: \n{1}\n\n' + 'On input line(s):\n{TAB}{2}\n\n' + 'we found output:\n{TAB}{3}\n\n' + 'instead of the expected:\n{TAB}{4}\n\n') + e = e.format(source, content, '\n'.join(input_lines), repr(found), + repr(submitted), TAB=TAB) + raise RuntimeError(e) + +# dict of allowable doctest handlers. The key represents the first argument +# that must be given to @doctest in order to activate the handler. +doctests = { + 'float': float_doctest, +} diff --git a/IPython/sphinxext/ipython_directive.py b/IPython/sphinxext/ipython_directive.py index 744aa57..d9e48f9 100644 --- a/IPython/sphinxext/ipython_directive.py +++ b/IPython/sphinxext/ipython_directive.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- -"""Sphinx directive to support embedded IPython code. +""" +Sphinx directive to support embedded IPython code. This directive allows pasting of entire interactive IPython sessions, prompts and all, and their code will actually get re-executed at doc build time, with @@ -9,11 +10,17 @@ like an interactive ipython section. To enable this directive, simply list it in your Sphinx ``conf.py`` file (making sure the directory where you placed it is visible to sphinx, as is -needed for all Sphinx directives). +needed for all Sphinx directives). For example, to enable syntax highlighting +and the IPython directive:: + + extensions = ['IPython.sphinxext.ipython_console_highlighting', + 'IPython.sphinxext.ipython_directive'] -By default this directive assumes that your prompts are unchanged IPython ones, -but this can be customized. The configurable options that can be placed in -conf.py are +The IPython directive outputs code-blocks with the language 'ipython'. So +if you do not have the syntax highlighting extension enabled as well, then +all rendered code-blocks will be uncolored. By default this directive assumes +that your prompts are unchanged IPython ones, but this can be customized. +The configurable options that can be placed in conf.py are: ipython_savefig_dir: The directory in which to save the figures. This is relative to the @@ -34,6 +41,47 @@ ipython_promptout: The string to represent the IPython prompt in the generated ReST. The default is 'Out [%d]:'. This expects that the line numbers are used in the prompt. +ipython_mplbackend: + The string which specifies if the embedded Sphinx shell should import + Matplotlib and set the backend. The value specifies a backend that is + passed to `matplotlib.use()` before any lines in `ipython_execlines` are + executed. If not specified in conf.py, then the default value of 'agg' is + used. To use the IPython directive without matplotlib as a dependency, set + the value to `None`. It may end up that matplotlib is still imported + if the user specifies so in `ipython_execlines` or makes use of the + @savefig pseudo decorator. +ipython_execlines: + A list of strings to be exec'd in the embedded Sphinx shell. Typical + usage is to make certain packages always available. Set this to an empty + list if you wish to have no imports always available. If specified in + conf.py as `None`, then it has the effect of making no imports available. + If omitted from conf.py altogether, then the default value of + ['import numpy as np', 'import matplotlib.pyplot as plt'] is used. +ipython_holdcount + When the @suppress pseudo-decorator is used, the execution count can be + incremented or not. The default behavior is to hold the execution count, + corresponding to a value of `True`. Set this to `False` to increment + the execution count after each suppressed command. + +As an example, to use the IPython directive when `matplotlib` is not available, +one sets the backend to `None`:: + + ipython_mplbackend = None + +An example usage of the directive is: + +.. code-block:: rst + + .. ipython:: + + In [1]: x = 1 + + In [2]: y = x**2 + + In [3]: print(y) + +See http://matplotlib.org/sampledoc/ipython_directive.html for additional +documentation. ToDo ---- @@ -70,14 +118,11 @@ except ImportError: from md5 import md5 # Third-party -import matplotlib import sphinx from docutils.parsers.rst import directives from docutils import nodes from sphinx.util.compat import Directive -matplotlib.use('Agg') - # Our own from IPython import Config, InteractiveShell from IPython.core.profiledir import ProfileDir @@ -98,6 +143,7 @@ COMMENT, INPUT, OUTPUT = range(3) #----------------------------------------------------------------------------- # Functions and class declarations #----------------------------------------------------------------------------- + def block_parser(part, rgxin, rgxout, fmtin, fmtout): """ part is a string of ipython text, comprised of at most one @@ -116,10 +162,9 @@ def block_parser(part, rgxin, rgxout, fmtin, fmtout): INPUT_LINE: the input as string (possibly multi-line) REST : any stdout generated by the input line (not OUTPUT) - OUTPUT: the output string, possibly multi-line - """ + """ block = [] lines = part.split('\n') N = len(lines) @@ -192,20 +237,19 @@ def block_parser(part, rgxin, rgxout, fmtin, fmtout): return block + class EmbeddedSphinxShell(object): """An embedded IPython instance to run inside Sphinx""" - def __init__(self): + def __init__(self, exec_lines=None): self.cout = StringIO() + if exec_lines is None: + exec_lines = [] # Create config object for IPython config = Config() - config.Global.display_banner = False - config.Global.exec_lines = ['import numpy as np', - 'from pylab import *' - ] config.InteractiveShell.autocall = False config.InteractiveShell.autoindent = False config.InteractiveShell.colors = 'NoColor' @@ -216,9 +260,11 @@ class EmbeddedSphinxShell(object): pdir = os.path.join(tmp_profile_dir,profname) profile = ProfileDir.create_profile_dir(pdir) - # Create and initialize ipython, but don't start its mainloop + # Create and initialize global ipython, but don't start its mainloop. + # This will persist across different EmbededSphinxShell instances. IP = InteractiveShell.instance(config=config, profile_dir=profile) - # io.stdout redirect must be done *after* instantiating InteractiveShell + + # io.stdout redirect must be done after instantiating InteractiveShell io.stdout = self.cout io.stderr = self.cout @@ -239,17 +285,24 @@ class EmbeddedSphinxShell(object): self.is_doctest = False self.is_suppress = False + # Optionally, provide more detailed information to shell. + self.directive = None + # on the first call to the savefig decorator, we'll import # pyplot as plt so we can make a call to the plt.gcf().savefig self._pyplot_imported = False + # Prepopulate the namespace. + for line in exec_lines: + self.process_input_line(line, store_history=False) + def clear_cout(self): self.cout.seek(0) self.cout.truncate(0) def process_input_line(self, line, store_history=True): """process the input, capturing stdout""" - #print "input='%s'"%self.input + stdout = sys.stdout splitter = self.IP.input_splitter try: @@ -291,16 +344,19 @@ class EmbeddedSphinxShell(object): image_directive = '\n'.join(imagerows) return image_file, image_directive - # Callbacks for each type of token def process_input(self, data, input_prompt, lineno): - """Process data block for INPUT token.""" + """ + Process data block for INPUT token. + + """ decorator, input, rest = data image_file = None image_directive = None - #print 'INPUT:', data # dbg + is_verbatim = decorator=='@verbatim' or self.is_verbatim - is_doctest = decorator=='@doctest' or self.is_doctest + is_doctest = (decorator is not None and \ + decorator.startswith('@doctest')) or self.is_doctest is_suppress = decorator=='@suppress' or self.is_suppress is_savefig = decorator is not None and \ decorator.startswith('@savefig') @@ -312,7 +368,6 @@ class EmbeddedSphinxShell(object): # so splitter buffer gets reset continuation = ' %s:'%''.join(['.']*(len(str(lineno))+2)) - Nc = len(continuation) if is_savefig: image_file, image_directive = self.process_image(decorator) @@ -320,23 +375,29 @@ class EmbeddedSphinxShell(object): ret = [] is_semicolon = False + # Hold the execution count, if requested to do so. + if is_suppress and self.hold_count: + store_history = False + else: + store_history = True + for i, line in enumerate(input_lines): if line.endswith(';'): is_semicolon = True - if i==0: + if i == 0: # process the first input line if is_verbatim: self.process_input_line('') self.IP.execution_count += 1 # increment it anyway else: # only submit the line in non-verbatim mode - self.process_input_line(line, store_history=True) + self.process_input_line(line, store_history=store_history) formatted_line = '%s %s'%(input_prompt, line) else: # process a continuation line if not is_verbatim: - self.process_input_line(line, store_history=True) + self.process_input_line(line, store_history=store_history) formatted_line = '%s %s'%(continuation, line) @@ -357,38 +418,61 @@ class EmbeddedSphinxShell(object): ret.append('') self.cout.truncate(0) - return (ret, input_lines, output, is_doctest, image_file, + return (ret, input_lines, output, is_doctest, decorator, image_file, image_directive) - #print 'OUTPUT', output # dbg + def process_output(self, data, output_prompt, - input_lines, output, is_doctest, image_file): - """Process data block for OUTPUT token.""" - if is_doctest: - submitted = data.strip() - found = output - if found is not None: - found = found.strip() + input_lines, output, is_doctest, decorator, image_file): + """ + Process data block for OUTPUT token. - # XXX - fperez: in 0.11, 'output' never comes with the prompt - # in it, just the actual output text. So I think all this code - # can be nuked... + """ + TAB = ' ' * 4 - # the above comment does not appear to be accurate... (minrk) + if is_doctest and output is not None: - ind = found.find(output_prompt) - if ind<0: - e='output prompt="%s" does not match out line=%s' % \ - (output_prompt, found) - raise RuntimeError(e) - found = found[len(output_prompt):].strip() + found = output + found = found.strip() + submitted = data.strip() - if found!=submitted: - e = ('doctest failure for input_lines="%s" with ' - 'found_output="%s" and submitted output="%s"' % - (input_lines, found, submitted) ) + if self.directive is None: + source = 'Unavailable' + content = 'Unavailable' + else: + source = self.directive.state.document.current_source + content = self.directive.content + # Add tabs and join into a single string. + content = '\n'.join([TAB + line for line in content]) + + # Make sure the output contains the output prompt. + ind = found.find(output_prompt) + if ind < 0: + e = ('output does not contain output prompt\n\n' + 'Document source: {0}\n\n' + 'Raw content: \n{1}\n\n' + 'Input line(s):\n{TAB}{2}\n\n' + 'Output line(s):\n{TAB}{3}\n\n') + e = e.format(source, content, '\n'.join(input_lines), + repr(found), TAB=TAB) + raise RuntimeError(e) + found = found[len(output_prompt):].strip() + + # Handle the actual doctest comparison. + if decorator.strip() == '@doctest': + # Standard doctest + if found != submitted: + e = ('doctest failure\n\n' + 'Document source: {0}\n\n' + 'Raw content: \n{1}\n\n' + 'On input line(s):\n{TAB}{2}\n\n' + 'we found output:\n{TAB}{3}\n\n' + 'instead of the expected:\n{TAB}{4}\n\n') + e = e.format(source, content, '\n'.join(input_lines), + repr(found), repr(submitted), TAB=TAB) raise RuntimeError(e) - #print 'doctest PASSED for input_lines="%s" with found_output="%s" and submitted output="%s"'%(input_lines, found, submitted) + else: + self.custom_doctest(decorator, input_lines, found, submitted) def process_comment(self, data): """Process data fPblock for COMMENT token.""" @@ -409,7 +493,6 @@ class EmbeddedSphinxShell(object): self.process_input_line('bookmark -d ipy_thisdir', store_history=False) self.clear_cout() - def process_block(self, block): """ process block from the block_parser and return a list of processed lines @@ -419,23 +502,23 @@ class EmbeddedSphinxShell(object): input_lines = None lineno = self.IP.execution_count - input_prompt = self.promptin%lineno - output_prompt = self.promptout%lineno + input_prompt = self.promptin % lineno + output_prompt = self.promptout % lineno image_file = None image_directive = None for token, data in block: - if token==COMMENT: + if token == COMMENT: out_data = self.process_comment(data) - elif token==INPUT: - (out_data, input_lines, output, is_doctest, image_file, - image_directive) = \ + elif token == INPUT: + (out_data, input_lines, output, is_doctest, decorator, + image_file, image_directive) = \ self.process_input(data, input_prompt, lineno) - elif token==OUTPUT: + elif token == OUTPUT: out_data = \ self.process_output(data, output_prompt, input_lines, output, is_doctest, - image_file) + decorator, image_file) if out_data: ret.extend(out_data) @@ -446,14 +529,34 @@ class EmbeddedSphinxShell(object): return ret, image_directive def ensure_pyplot(self): - if self._pyplot_imported: - return - self.process_input_line('import matplotlib.pyplot as plt', - store_history=False) + """ + Ensures that pyplot has been imported into the embedded IPython shell. + + Also, makes sure to set the backend appropriately if not set already. + + """ + # We are here if the @figure pseudo decorator was used. Thus, it's + # possible that we could be here even if python_mplbackend were set to + # `None`. That's also strange and perhaps worthy of raising an + # exception, but for now, we just set the backend to 'agg'. + + if not self._pyplot_imported: + if 'matplotlib.backends' not in sys.modules: + # Then ipython_matplotlib was set to None but there was a + # call to the @figure decorator (and ipython_execlines did + # not set a backend). + #raise Exception("No backend was set, but @figure was used!") + import matplotlib + matplotlib.use('agg') + + # Always import pyplot into embedded shell. + self.process_input_line('import matplotlib.pyplot as plt', + store_history=False) + self._pyplot_imported = True def process_pure_python(self, content): """ - content is a list of strings. it is unedited directive conent + content is a list of strings. it is unedited directive content This runs it line by line in the InteractiveShell, prepends prompts as needed capturing stderr and stdout, then returns @@ -529,6 +632,22 @@ class EmbeddedSphinxShell(object): return output + def custom_doctest(self, decorator, input_lines, found, submitted): + """ + Perform a specialized doctest. + + """ + from .custom_doctests import doctests + + args = decorator.split() + doctest_type = args[1] + if doctest_type in doctests: + doctests[doctest_type](self, args, input_lines, found, submitted) + else: + e = "Invalid option to @doctest: {0}".format(doctest_type) + raise Exception(e) + + class IPythonDirective(Directive): has_content = True @@ -560,30 +679,48 @@ class IPythonDirective(Directive): savefig_dir = os.path.join(confdir, savefig_dir) # get regex and prompt stuff - rgxin = config.ipython_rgxin - rgxout = config.ipython_rgxout - promptin = config.ipython_promptin - promptout = config.ipython_promptout + rgxin = config.ipython_rgxin + rgxout = config.ipython_rgxout + promptin = config.ipython_promptin + promptout = config.ipython_promptout + mplbackend = config.ipython_mplbackend + exec_lines = config.ipython_execlines + hold_count = config.ipython_holdcount - return savefig_dir, source_dir, rgxin, rgxout, promptin, promptout + return (savefig_dir, source_dir, rgxin, rgxout, + promptin, promptout, mplbackend, exec_lines, hold_count) def setup(self): + # Get configuration values. + (savefig_dir, source_dir, rgxin, rgxout, promptin, promptout, + mplbackend, exec_lines, hold_count) = self.get_config_options() + if self.shell is None: - self.shell = EmbeddedSphinxShell() - # reset the execution count if we haven't processed this doc - #NOTE: this may be borked if there are multiple seen_doc tmp files - #check time stamp? + # We will be here many times. However, when the + # EmbeddedSphinxShell is created, its interactive shell member + # is the same for each instance. - if not self.state.document.current_source in self.seen_docs: - self.shell.IP.history_manager.reset() - self.shell.IP.execution_count = 1 - self.seen_docs.add(self.state.document.current_source) + if mplbackend: + import matplotlib + # Repeated calls to use() will not hurt us since `mplbackend` + # is the same each time. + matplotlib.use(mplbackend) + # Must be called after (potentially) importing matplotlib and + # setting its backend since exec_lines might import pylab. + self.shell = EmbeddedSphinxShell(exec_lines) + # Store IPython directive to enable better error messages + self.shell.directive = self - # get config values - (savefig_dir, source_dir, rgxin, - rgxout, promptin, promptout) = self.get_config_options() + # reset the execution count if we haven't processed this doc + #NOTE: this may be borked if there are multiple seen_doc tmp files + #check time stamp? + if not self.state.document.current_source in self.seen_docs: + self.shell.IP.history_manager.reset() + self.shell.IP.execution_count = 1 + self.shell.IP.prompt_manager.width = 0 + self.seen_docs.add(self.state.document.current_source) # and attach to shell so we don't have to pass them around self.shell.rgxin = rgxin @@ -592,16 +729,15 @@ class IPythonDirective(Directive): self.shell.promptout = promptout self.shell.savefig_dir = savefig_dir self.shell.source_dir = source_dir + self.shell.hold_count = hold_count # setup bookmark for saving figures directory - self.shell.process_input_line('bookmark ipy_savedir %s'%savefig_dir, store_history=False) self.shell.clear_cout() return rgxin, rgxout, promptin, promptout - def teardown(self): # delete last bookmark self.shell.process_input_line('bookmark -d ipy_savedir', @@ -620,7 +756,6 @@ class IPythonDirective(Directive): self.shell.is_doctest = 'doctest' in options self.shell.is_verbatim = 'verbatim' in options - # handle pure python code if 'python' in self.arguments: content = self.content @@ -628,13 +763,11 @@ class IPythonDirective(Directive): parts = '\n'.join(self.content).split('\n\n') - lines = ['.. code-block:: ipython',''] + lines = ['.. code-block:: ipython', ''] figures = [] for part in parts: - block = block_parser(part, rgxin, rgxout, promptin, promptout) - if len(block): rows, figure = self.shell.process_block(block) for row in rows: @@ -643,46 +776,51 @@ class IPythonDirective(Directive): if figure is not None: figures.append(figure) - #text = '\n'.join(lines) - #figs = '\n'.join(figures) - for figure in figures: lines.append('') lines.extend(figure.split('\n')) lines.append('') - #print lines if len(lines)>2: if debug: print('\n'.join(lines)) - else: #NOTE: this raises some errors, what's it for? - #print 'INSERTING %d lines'%len(lines) + else: + # This has to do with input, not output. But if we comment + # these lines out, then no IPython code will appear in the + # final output. self.state_machine.insert_input( lines, self.state_machine.input_lines.source(0)) - text = '\n'.join(lines) - txtnode = nodes.literal_block(text, text) - txtnode['language'] = 'ipython' - #imgnode = nodes.image(figs) - # cleanup self.teardown() - return []#, imgnode] + return [] # Enable as a proper Sphinx directive def setup(app): setup.app = app app.add_directive('ipython', IPythonDirective) - app.add_config_value('ipython_savefig_dir', None, True) + app.add_config_value('ipython_savefig_dir', None, 'env') app.add_config_value('ipython_rgxin', - re.compile('In \[(\d+)\]:\s?(.*)\s*'), True) + re.compile('In \[(\d+)\]:\s?(.*)\s*'), 'env') app.add_config_value('ipython_rgxout', - re.compile('Out\[(\d+)\]:\s?(.*)\s*'), True) - app.add_config_value('ipython_promptin', 'In [%d]:', True) - app.add_config_value('ipython_promptout', 'Out[%d]:', True) + re.compile('Out\[(\d+)\]:\s?(.*)\s*'), 'env') + app.add_config_value('ipython_promptin', 'In [%d]:', 'env') + app.add_config_value('ipython_promptout', 'Out[%d]:', 'env') + # We could just let matplotlib pick whatever is specified as the default + # backend in the matplotlibrc file, but this would cause issues if the + # backend didn't work in headless environments. For this reason, 'agg' + # is a good default backend choice. + app.add_config_value('ipython_mplbackend', 'agg', 'env') + + # If the user sets this config value to `None`, then EmbeddedSphinxShell's + # __init__ method will treat it as []. + execlines = ['import numpy as np', 'import matplotlib.pyplot as plt'] + app.add_config_value('ipython_execlines', execlines, 'env') + + app.add_config_value('ipython_holdcount', True, 'env') # Simple smoke test, needs to be converted to a proper automatic test. def test(): @@ -810,6 +948,33 @@ In [152]: title('normal distribution') @savefig hist_with_text.png In [153]: grid(True) +@doctest float +In [154]: 0.1 + 0.2 +Out[154]: 0.3 + +@doctest float +In [155]: np.arange(16).reshape(4,4) +Out[155]: +array([[ 0, 1, 2, 3], + [ 4, 5, 6, 7], + [ 8, 9, 10, 11], + [12, 13, 14, 15]]) + +In [1]: x = np.arange(16, dtype=float).reshape(4,4) + +In [2]: x[0,0] = np.inf + +In [3]: x[0,1] = np.nan + +@doctest float +In [4]: x +Out[4]: +array([[ inf, nan, 2., 3.], + [ 4., 5., 6., 7.], + [ 8., 9., 10., 11.], + [ 12., 13., 14., 15.]]) + + """, ] # skip local-file depending first example: