diff --git a/doc/ext/sphinx_gallery/__init__.py b/doc/ext/sphinx_gallery/__init__.py new file mode 100644 index 00000000..c5ff4151 --- /dev/null +++ b/doc/ext/sphinx_gallery/__init__.py @@ -0,0 +1,13 @@ +""" +============== +Sphinx Gallery +============== + +""" +import os +__version__ = '0.1.2' + + +def glr_path_static(): + """Returns path to packaged static files""" + return os.path.abspath(os.path.join(os.path.dirname(__file__), '_static')) diff --git a/doc/ext/sphinx_gallery/_static/broken_example.png b/doc/ext/sphinx_gallery/_static/broken_example.png new file mode 100644 index 00000000..4fea24e7 Binary files /dev/null and b/doc/ext/sphinx_gallery/_static/broken_example.png differ diff --git a/doc/ext/sphinx_gallery/_static/broken_stamp.svg b/doc/ext/sphinx_gallery/_static/broken_stamp.svg new file mode 100644 index 00000000..3aa3671d --- /dev/null +++ b/doc/ext/sphinx_gallery/_static/broken_stamp.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/doc/ext/sphinx_gallery/_static/gallery.css b/doc/ext/sphinx_gallery/_static/gallery.css new file mode 100644 index 00000000..dd599b40 --- /dev/null +++ b/doc/ext/sphinx_gallery/_static/gallery.css @@ -0,0 +1,127 @@ +/* +Sphinx-Gallery is has compatible CSS to fix default sphinx themes +Tested for Sphinx 1.3.1 for all themes: default, alabaster, sphinxdoc, +scrolls, agogo, traditional, nature, haiku, pyramid +Tested for Read the docs theme 0.1.7 */ +.sphx-glr-thumbcontainer { + background: #fff; + border: solid #fff 1px; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; + box-shadow: none; + float: left; + margin: 5px; + min-height: 230px; + padding-top: 5px; + position: relative; +} +.sphx-glr-thumbcontainer:hover { + border: solid #b4ddfc 1px; + box-shadow: 0 0 15px rgba(142, 176, 202, 0.5); +} +.sphx-glr-thumbcontainer a.internal { + bottom: 0; + display: block; + left: 0; + padding: 150px 10px 0; + position: absolute; + right: 0; + top: 0; +} +/* Next one is to avoid Sphinx traditional theme to cover all the +thumbnail with its default link Background color */ +.sphx-glr-thumbcontainer a.internal:hover { + background-color: transparent; +} + +.sphx-glr-thumbcontainer p { + margin: 0 0 .1em 0; +} +.sphx-glr-thumbcontainer .figure { + margin: 10px; + width: 160px; +} +.sphx-glr-thumbcontainer img { + display: inline; + max-height: 160px; + width: 160px; +} +.sphx-glr-thumbcontainer[tooltip]:hover:after { + background: rgba(0, 0, 0, 0.8); + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + color: #fff; + content: attr(tooltip); + left: 95%; + padding: 5px 15px; + position: absolute; + z-index: 98; + width: 220px; + bottom: 52%; +} +.sphx-glr-thumbcontainer[tooltip]:hover:before { + border: solid; + border-color: #333 transparent; + border-width: 18px 0 0 20px; + bottom: 58%; + content: ''; + left: 85%; + position: absolute; + z-index: 99; +} + +.highlight-pytb pre { + background-color: #ffe4e4; + border: 1px solid #f66; + margin-top: 10px; + padding: 7px; +} + +.sphx-glr-script-out { + color: #888; + margin: 0; +} +.sphx-glr-script-out .highlight { + background-color: transparent; + margin-left: 2.5em; + margin-top: -1.4em; +} +.sphx-glr-script-out .highlight pre { + background-color: #fafae2; + border: 0; + max-height: 30em; + overflow: auto; + padding-left: 1ex; + margin: 0px; + word-break: break-word; +} +.sphx-glr-script-out + p { + margin-top: 1.8em; +} +blockquote.sphx-glr-script-out { + margin-left: 0pt; +} +.sphx-glr-download { + background-color: #ffc; + border: 1px solid #c2c22d; + border-radius: 4px; + margin: 1em auto 1ex auto; + max-width: 45ex; + padding: 1ex; +} +.sphx-glr-download a { + color: #4b4600; +} + +ul.sphx-glr-horizontal { + list-style: none; + padding: 0; +} +ul.sphx-glr-horizontal li { + display: inline; +} +ul.sphx-glr-horizontal img { + height: auto !important; +} diff --git a/doc/ext/sphinx_gallery/_static/no_image.png b/doc/ext/sphinx_gallery/_static/no_image.png new file mode 100644 index 00000000..8c2d48d5 Binary files /dev/null and b/doc/ext/sphinx_gallery/_static/no_image.png differ diff --git a/doc/ext/sphinx_gallery/backreferences.py b/doc/ext/sphinx_gallery/backreferences.py new file mode 100644 index 00000000..4fe579c6 --- /dev/null +++ b/doc/ext/sphinx_gallery/backreferences.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +# Author: Óscar Nájera +# License: 3-clause BSD +""" +======================== +Backreferences Generator +======================== + +Reviews generated example files in order to keep track of used modules +""" + +from __future__ import print_function +import ast +import os + + +# Try Python 2 first, otherwise load from Python 3 +try: + import cPickle as pickle +except ImportError: + import pickle + + +class NameFinder(ast.NodeVisitor): + """Finds the longest form of variable names and their imports in code + + Only retains names from imported modules. + """ + + def __init__(self): + super(NameFinder, self).__init__() + self.imported_names = {} + self.accessed_names = set() + + def visit_Import(self, node, prefix=''): + for alias in node.names: + local_name = alias.asname or alias.name + self.imported_names[local_name] = prefix + alias.name + + def visit_ImportFrom(self, node): + self.visit_Import(node, node.module + '.') + + def visit_Name(self, node): + self.accessed_names.add(node.id) + + def visit_Attribute(self, node): + attrs = [] + while isinstance(node, ast.Attribute): + attrs.append(node.attr) + node = node.value + + if isinstance(node, ast.Name): + # This is a.b, not e.g. a().b + attrs.append(node.id) + self.accessed_names.add('.'.join(reversed(attrs))) + else: + # need to get a in a().b + self.visit(node) + + def get_mapping(self): + for name in self.accessed_names: + local_name = name.split('.', 1)[0] + remainder = name[len(local_name):] + if local_name in self.imported_names: + # Join import path to relative path + full_name = self.imported_names[local_name] + remainder + yield name, full_name + + +def get_short_module_name(module_name, obj_name): + """ Get the shortest possible module name """ + parts = module_name.split('.') + short_name = module_name + for i in range(len(parts) - 1, 0, -1): + short_name = '.'.join(parts[:i]) + try: + exec('from %s import %s' % (short_name, obj_name)) + except Exception: # libraries can throw all sorts of exceptions... + # get the last working module name + short_name = '.'.join(parts[:(i + 1)]) + break + return short_name + + +def identify_names(code): + """Builds a codeobj summary by identifying and resolving used names + + >>> code = ''' + ... from a.b import c + ... import d as e + ... print(c) + ... e.HelloWorld().f.g + ... ''' + >>> for name, o in sorted(identify_names(code).items()): + ... print(name, o['name'], o['module'], o['module_short']) + c c a.b a.b + e.HelloWorld HelloWorld d d + """ + finder = NameFinder() + finder.visit(ast.parse(code)) + + example_code_obj = {} + for name, full_name in finder.get_mapping(): + # name is as written in file (e.g. np.asarray) + # full_name includes resolved import path (e.g. numpy.asarray) + module, attribute = full_name.rsplit('.', 1) + # get shortened module name + module_short = get_short_module_name(module, attribute) + cobj = {'name': attribute, 'module': module, + 'module_short': module_short} + example_code_obj[name] = cobj + return example_code_obj + + +def scan_used_functions(example_file, gallery_conf): + """save variables so we can later add links to the documentation""" + example_code_obj = identify_names(open(example_file).read()) + if example_code_obj: + codeobj_fname = example_file[:-3] + '_codeobj.pickle' + with open(codeobj_fname, 'wb') as fid: + pickle.dump(example_code_obj, fid, pickle.HIGHEST_PROTOCOL) + + backrefs = set('{module_short}.{name}'.format(**entry) + for entry in example_code_obj.values() + if entry['module'].startswith(gallery_conf['doc_module'])) + + return backrefs + + +THUMBNAIL_TEMPLATE = """ +.. raw:: html + +
+ +.. only:: html + + .. figure:: /{thumbnail} + + :ref:`sphx_glr_{ref_name}` + +.. raw:: html + +
+""" + +BACKREF_THUMBNAIL_TEMPLATE = THUMBNAIL_TEMPLATE + """ +.. only:: not html + + * :ref:`sphx_glr_{ref_name}` +""" + + +def _thumbnail_div(full_dir, fname, snippet, is_backref=False): + """Generates RST to place a thumbnail in a gallery""" + thumb = os.path.join(full_dir, 'images', 'thumb', + 'sphx_glr_%s_thumb.png' % fname[:-3]) + ref_name = os.path.join(full_dir, fname).replace(os.path.sep, '_') + + template = BACKREF_THUMBNAIL_TEMPLATE if is_backref else THUMBNAIL_TEMPLATE + return template.format(snippet=snippet, thumbnail=thumb, ref_name=ref_name) + + +def write_backreferences(seen_backrefs, gallery_conf, + target_dir, fname, snippet): + """Writes down back reference files, which include a thumbnail list + of examples using a certain module""" + example_file = os.path.join(target_dir, fname) + backrefs = scan_used_functions(example_file, gallery_conf) + for backref in backrefs: + include_path = os.path.join(gallery_conf['mod_example_dir'], + '%s.examples' % backref) + seen = backref in seen_backrefs + with open(include_path, 'a' if seen else 'w') as ex_file: + if not seen: + heading = '\n\nExamples using ``%s``' % backref + ex_file.write(heading + '\n') + ex_file.write('^' * len(heading) + '\n') + ex_file.write(_thumbnail_div(target_dir, fname, snippet, + is_backref=True)) + seen_backrefs.add(backref) diff --git a/doc/ext/sphinx_gallery/docs_resolv.py b/doc/ext/sphinx_gallery/docs_resolv.py new file mode 100644 index 00000000..0675a131 --- /dev/null +++ b/doc/ext/sphinx_gallery/docs_resolv.py @@ -0,0 +1,436 @@ +# -*- coding: utf-8 -*- +# Author: Óscar Nájera +# License: 3-clause BSD +############################################################################### +# Documentation link resolver objects +from __future__ import print_function +import gzip +import os +import posixpath +import re +import shelve +import sys + +# Try Python 2 first, otherwise load from Python 3 +try: + import cPickle as pickle + import urllib2 as urllib + from urllib2 import HTTPError, URLError +except ImportError: + import pickle + import urllib.request + import urllib.error + import urllib.parse + from urllib.error import HTTPError, URLError + +from io import StringIO + + +def _get_data(url): + """Helper function to get data over http or from a local file""" + if url.startswith('http://'): + # Try Python 2, use Python 3 on exception + try: + resp = urllib.urlopen(url) + encoding = resp.headers.dict.get('content-encoding', 'plain') + except AttributeError: + resp = urllib.request.urlopen(url) + encoding = resp.headers.get('content-encoding', 'plain') + data = resp.read() + if encoding == 'plain': + pass + elif encoding == 'gzip': + data = StringIO(data) + data = gzip.GzipFile(fileobj=data).read() + else: + raise RuntimeError('unknown encoding') + else: + with open(url, 'r') as fid: + data = fid.read() + + return data + + +def get_data(url, gallery_dir): + """Persistent dictionary usage to retrieve the search indexes""" + + # shelve keys need to be str in python 2 + if sys.version_info[0] == 2 and isinstance(url, unicode): + url = url.encode('utf-8') + + cached_file = os.path.join(gallery_dir, 'searchindex') + search_index = shelve.open(cached_file) + if url in search_index: + data = search_index[url] + else: + data = _get_data(url) + search_index[url] = data + search_index.close() + + return data + + +def _select_block(str_in, start_tag, end_tag): + """Select first block delimited by start_tag and end_tag""" + start_pos = str_in.find(start_tag) + if start_pos < 0: + raise ValueError('start_tag not found') + depth = 0 + for pos in range(start_pos, len(str_in)): + if str_in[pos] == start_tag: + depth += 1 + elif str_in[pos] == end_tag: + depth -= 1 + + if depth == 0: + break + sel = str_in[start_pos + 1:pos] + return sel + + +def _parse_dict_recursive(dict_str): + """Parse a dictionary from the search index""" + dict_out = dict() + pos_last = 0 + pos = dict_str.find(':') + while pos >= 0: + key = dict_str[pos_last:pos] + if dict_str[pos + 1] == '[': + # value is a list + pos_tmp = dict_str.find(']', pos + 1) + if pos_tmp < 0: + raise RuntimeError('error when parsing dict') + value = dict_str[pos + 2: pos_tmp].split(',') + # try to convert elements to int + for i in range(len(value)): + try: + value[i] = int(value[i]) + except ValueError: + pass + elif dict_str[pos + 1] == '{': + # value is another dictionary + subdict_str = _select_block(dict_str[pos:], '{', '}') + value = _parse_dict_recursive(subdict_str) + pos_tmp = pos + len(subdict_str) + else: + raise ValueError('error when parsing dict: unknown elem') + + key = key.strip('"') + if len(key) > 0: + dict_out[key] = value + + pos_last = dict_str.find(',', pos_tmp) + if pos_last < 0: + break + pos_last += 1 + pos = dict_str.find(':', pos_last) + + return dict_out + + +def parse_sphinx_searchindex(searchindex): + """Parse a Sphinx search index + + Parameters + ---------- + searchindex : str + The Sphinx search index (contents of searchindex.js) + + Returns + ------- + filenames : list of str + The file names parsed from the search index. + objects : dict + The objects parsed from the search index. + """ + # Make sure searchindex uses UTF-8 encoding + if hasattr(searchindex, 'decode'): + searchindex = searchindex.decode('UTF-8') + + # parse objects + query = 'objects:' + pos = searchindex.find(query) + if pos < 0: + raise ValueError('"objects:" not found in search index') + + sel = _select_block(searchindex[pos:], '{', '}') + objects = _parse_dict_recursive(sel) + + # parse filenames + query = 'filenames:' + pos = searchindex.find(query) + if pos < 0: + raise ValueError('"filenames:" not found in search index') + filenames = searchindex[pos + len(query) + 1:] + filenames = filenames[:filenames.find(']')] + filenames = [f.strip('"') for f in filenames.split(',')] + + return filenames, objects + + +class SphinxDocLinkResolver(object): + """ Resolve documentation links using searchindex.js generated by Sphinx + + Parameters + ---------- + doc_url : str + The base URL of the project website. + searchindex : str + Filename of searchindex, relative to doc_url. + extra_modules_test : list of str + List of extra module names to test. + relative : bool + Return relative links (only useful for links to documentation of this + package). + """ + + def __init__(self, doc_url, gallery_dir, searchindex='searchindex.js', + extra_modules_test=None, relative=False): + self.doc_url = doc_url + self.gallery_dir = gallery_dir + self.relative = relative + self._link_cache = {} + + self.extra_modules_test = extra_modules_test + self._page_cache = {} + if doc_url.startswith('http://'): + if relative: + raise ValueError('Relative links are only supported for local ' + 'URLs (doc_url cannot start with "http://)"') + searchindex_url = doc_url + '/' + searchindex + else: + searchindex_url = os.path.join(doc_url, searchindex) + + # detect if we are using relative links on a Windows system + if os.name.lower() == 'nt' and not doc_url.startswith('http://'): + if not relative: + raise ValueError('You have to use relative=True for the local' + ' package on a Windows system.') + self._is_windows = True + else: + self._is_windows = False + + # download and initialize the search index + sindex = get_data(searchindex_url, gallery_dir) + filenames, objects = parse_sphinx_searchindex(sindex) + + self._searchindex = dict(filenames=filenames, objects=objects) + + def _get_link(self, cobj): + """Get a valid link, False if not found""" + + fname_idx = None + full_name = cobj['module_short'] + '.' + cobj['name'] + if full_name in self._searchindex['objects']: + value = self._searchindex['objects'][full_name] + if isinstance(value, dict): + value = value[next(iter(value.keys()))] + fname_idx = value[0] + elif cobj['module_short'] in self._searchindex['objects']: + value = self._searchindex['objects'][cobj['module_short']] + if cobj['name'] in value.keys(): + fname_idx = value[cobj['name']][0] + + if fname_idx is not None: + fname = self._searchindex['filenames'][fname_idx] + '.html' + + if self._is_windows: + fname = fname.replace('/', '\\') + link = os.path.join(self.doc_url, fname) + else: + link = posixpath.join(self.doc_url, fname) + + if hasattr(link, 'decode'): + link = link.decode('utf-8', 'replace') + + if link in self._page_cache: + html = self._page_cache[link] + else: + html = get_data(link, self.gallery_dir) + self._page_cache[link] = html + + # test if cobj appears in page + comb_names = [cobj['module_short'] + '.' + cobj['name']] + if self.extra_modules_test is not None: + for mod in self.extra_modules_test: + comb_names.append(mod + '.' + cobj['name']) + url = False + if hasattr(html, 'decode'): + # Decode bytes under Python 3 + html = html.decode('utf-8', 'replace') + + for comb_name in comb_names: + if hasattr(comb_name, 'decode'): + # Decode bytes under Python 3 + comb_name = comb_name.decode('utf-8', 'replace') + if comb_name in html: + url = link + u'#' + comb_name + link = url + else: + link = False + + return link + + def resolve(self, cobj, this_url): + """Resolve the link to the documentation, returns None if not found + + Parameters + ---------- + cobj : dict + Dict with information about the "code object" for which we are + resolving a link. + cobi['name'] : function or class name (str) + cobj['module_short'] : shortened module name (str) + cobj['module'] : module name (str) + this_url: str + URL of the current page. Needed to construct relative URLs + (only used if relative=True in constructor). + + Returns + ------- + link : str | None + The link (URL) to the documentation. + """ + full_name = cobj['module_short'] + '.' + cobj['name'] + link = self._link_cache.get(full_name, None) + if link is None: + # we don't have it cached + link = self._get_link(cobj) + # cache it for the future + self._link_cache[full_name] = link + + if link is False or link is None: + # failed to resolve + return None + + if self.relative: + link = os.path.relpath(link, start=this_url) + if self._is_windows: + # replace '\' with '/' so it on the web + link = link.replace('\\', '/') + + # for some reason, the relative link goes one directory too high up + link = link[3:] + + return link + + +def _embed_code_links(app, gallery_conf, gallery_dir): + # Add resolvers for the packages for which we want to show links + doc_resolvers = {} + + for this_module, url in gallery_conf['reference_url'].items(): + try: + if url is None: + doc_resolvers[this_module] = SphinxDocLinkResolver( + app.builder.outdir, + gallery_dir, + relative=True) + else: + doc_resolvers[this_module] = SphinxDocLinkResolver(url, + gallery_dir) + + except HTTPError as e: + print("The following HTTP Error has occurred:\n") + print(e.code) + except URLError as e: + print("\n...\n" + "Warning: Embedding the documentation hyperlinks requires " + "Internet access.\nPlease check your network connection.\n" + "Unable to continue embedding `{0}` links due to a URL " + "Error:\n".format(this_module)) + print(e.args) + + html_gallery_dir = os.path.abspath(os.path.join(app.builder.outdir, + gallery_dir)) + + # patterns for replacement + link_pattern = '%s' + orig_pattern = '%s' + period = '.' + + for dirpath, _, filenames in os.walk(html_gallery_dir): + for fname in filenames: + print('\tprocessing: %s' % fname) + full_fname = os.path.join(html_gallery_dir, dirpath, fname) + subpath = dirpath[len(html_gallery_dir) + 1:] + pickle_fname = os.path.join(gallery_dir, subpath, + fname[:-5] + '_codeobj.pickle') + + if os.path.exists(pickle_fname): + # we have a pickle file with the objects to embed links for + with open(pickle_fname, 'rb') as fid: + example_code_obj = pickle.load(fid) + fid.close() + str_repl = {} + # generate replacement strings with the links + for name, cobj in example_code_obj.items(): + this_module = cobj['module'].split('.')[0] + + if this_module not in doc_resolvers: + continue + + try: + link = doc_resolvers[this_module].resolve(cobj, + full_fname) + except (HTTPError, URLError) as e: + print("The following error has occurred:\n") + print(repr(e)) + continue + + if link is not None: + parts = name.split('.') + name_html = period.join(orig_pattern % part + for part in parts) + str_repl[name_html] = link_pattern % (link, name_html) + # do the replacement in the html file + + # ensure greediness + names = sorted(str_repl, key=len, reverse=True) + expr = re.compile(r'(? 0: + with open(full_fname, 'rb') as fid: + lines_in = fid.readlines() + with open(full_fname, 'wb') as fid: + for line in lines_in: + line = line.decode('utf-8') + line = expr.sub(substitute_link, line) + fid.write(line.encode('utf-8')) + print('[done]') + + +def embed_code_links(app, exception): + """Embed hyperlinks to documentation into example code""" + if exception is not None: + return + + # No need to waste time embedding hyperlinks when not running the examples + # XXX: also at the time of writing this fixes make html-noplot + # for some reason I don't fully understand + if not app.builder.config.plot_gallery: + return + + # XXX: Whitelist of builders for which it makes sense to embed + # hyperlinks inside the example html. Note that the link embedding + # require searchindex.js to exist for the links to the local doc + # and there does not seem to be a good way of knowing which + # builders creates a searchindex.js. + if app.builder.name not in ['html', 'readthedocs']: + return + + print('Embedding documentation hyperlinks in examples..') + + gallery_conf = app.config.sphinx_gallery_conf + + gallery_dirs = gallery_conf['gallery_dirs'] + if not isinstance(gallery_dirs, list): + gallery_dirs = [gallery_dirs] + + for gallery_dir in gallery_dirs: + _embed_code_links(app, gallery_conf, gallery_dir) diff --git a/doc/ext/sphinx_gallery/gen_gallery.py b/doc/ext/sphinx_gallery/gen_gallery.py new file mode 100644 index 00000000..3da9a59a --- /dev/null +++ b/doc/ext/sphinx_gallery/gen_gallery.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +# Author: Óscar Nájera +# License: 3-clause BSD +""" +======================== +Sphinx-Gallery Generator +======================== + +Attaches Sphinx-Gallery to Sphinx in order to generate the galleries +when building the documentation. +""" + + +from __future__ import division, print_function, absolute_import +import re +import os +from . import glr_path_static +from .gen_rst import generate_dir_rst +from .docs_resolv import embed_code_links + + +def clean_gallery_out(build_dir): + """Deletes images under the sphx_glr namespace in the build directory""" + # Sphinx hack: sphinx copies generated images to the build directory + # each time the docs are made. If the desired image name already + # exists, it appends a digit to prevent overwrites. The problem is, + # the directory is never cleared. This means that each time you build + # the docs, the number of images in the directory grows. + # + # This question has been asked on the sphinx development list, but there + # was no response: http://osdir.com/ml/sphinx-dev/2011-02/msg00123.html + # + # The following is a hack that prevents this behavior by clearing the + # image build directory from gallery images each time the docs are built. + # If sphinx changes their layout between versions, this will not + # work (though it should probably not cause a crash). + # Tested successfully on Sphinx 1.0.7 + + build_image_dir = os.path.join(build_dir, '_images') + if os.path.exists(build_image_dir): + filelist = os.listdir(build_image_dir) + for filename in filelist: + if filename.startswith('sphx_glr') and filename.endswith('png'): + os.remove(os.path.join(build_image_dir, filename)) + + +def generate_gallery_rst(app): + """Generate the Main examples gallery reStructuredText + + Start the sphinx-gallery configuration and recursively scan the examples + directories in order to populate the examples gallery + """ + try: + plot_gallery = eval(app.builder.config.plot_gallery) + except TypeError: + plot_gallery = bool(app.builder.config.plot_gallery) + + gallery_conf.update(app.config.sphinx_gallery_conf) + gallery_conf.update(plot_gallery=plot_gallery) + gallery_conf.update(abort_on_example_error=app.builder.config.abort_on_example_error) + + # this assures I can call the config in other places + app.config.sphinx_gallery_conf = gallery_conf + app.config.html_static_path.append(glr_path_static()) + + clean_gallery_out(app.builder.outdir) + + examples_dirs = gallery_conf['examples_dirs'] + gallery_dirs = gallery_conf['gallery_dirs'] + + if not isinstance(examples_dirs, list): + examples_dirs = [examples_dirs] + if not isinstance(gallery_dirs, list): + gallery_dirs = [gallery_dirs] + + mod_examples_dir = os.path.relpath(gallery_conf['mod_example_dir'], + app.builder.srcdir) + seen_backrefs = set() + + computation_times = [] + + # cd to the appropriate directory regardless of sphinx configuration + working_dir = os.getcwd() + os.chdir(app.builder.srcdir) + for examples_dir, gallery_dir in zip(examples_dirs, gallery_dirs): + examples_dir = os.path.relpath(examples_dir, + app.builder.srcdir) + gallery_dir = os.path.relpath(gallery_dir, + app.builder.srcdir) + + for workdir in [examples_dir, gallery_dir, mod_examples_dir]: + if not os.path.exists(workdir): + os.makedirs(workdir) + # we create an index.rst with all examples + fhindex = open(os.path.join(gallery_dir, 'index.rst'), 'w') + # Here we don't use an os.walk, but we recurse only twice: flat is + # better than nested. + this_fhindex, this_computation_times = \ + generate_dir_rst(examples_dir, gallery_dir, gallery_conf, + seen_backrefs) + + computation_times += this_computation_times + + fhindex.write(this_fhindex) + for directory in sorted(os.listdir(examples_dir)): + if os.path.isdir(os.path.join(examples_dir, directory)): + src_dir = os.path.join(examples_dir, directory) + target_dir = os.path.join(gallery_dir, directory) + this_fhindex, this_computation_times = \ + generate_dir_rst(src_dir, target_dir, gallery_conf, + seen_backrefs) + fhindex.write(this_fhindex) + computation_times += this_computation_times + + fhindex.flush() + + # Back to initial directory + os.chdir(working_dir) + + print("Computation time summary:") + for time_elapsed, fname in sorted(computation_times)[::-1]: + if time_elapsed is not None: + print("\t- %s : %.2g sec" % (fname, time_elapsed)) + else: + print("\t- %s : not run" % fname) + + +def touch_empty_backreferences(app, what, name, obj, options, lines): + """Generate empty back-reference example files + + This avoids inclusion errors/warnings if there are no gallery + examples for a class / module that is being parsed by autodoc""" + + examples_path = os.path.join(app.srcdir, + app.config.sphinx_gallery_conf["mod_example_dir"], + "%s.examples" % name) + + if not os.path.exists(examples_path): + # touch file + open(examples_path, 'w').close() + + +gallery_conf = { + 'filename_pattern': re.escape(os.sep) + 'plot', + 'examples_dirs': '../examples', + 'gallery_dirs': 'auto_examples', + 'mod_example_dir': os.path.join('modules', 'generated'), + 'doc_module': (), + 'reference_url': {}, +} + + +def setup(app): + """Setup sphinx-gallery sphinx extension""" + app.add_config_value('plot_gallery', True, 'html') + app.add_config_value('abort_on_example_error', False, 'html') + app.add_config_value('sphinx_gallery_conf', gallery_conf, 'html') + app.add_stylesheet('gallery.css') + + if 'sphinx.ext.autodoc' in app._extensions: + app.connect('autodoc-process-docstring', touch_empty_backreferences) + + app.connect('builder-inited', generate_gallery_rst) + + app.connect('build-finished', embed_code_links) + + +def setup_module(): + # HACK: Stop nosetests running setup() above + pass diff --git a/doc/ext/sphinx_gallery/gen_rst.py b/doc/ext/sphinx_gallery/gen_rst.py new file mode 100644 index 00000000..318bc60f --- /dev/null +++ b/doc/ext/sphinx_gallery/gen_rst.py @@ -0,0 +1,656 @@ +# -*- coding: utf-8 -*- +# Author: Óscar Nájera +# License: 3-clause BSD +""" +================== +RST file generator +================== + +Generate the rst files for the examples by iterating over the python +example files. + +Files that generate images should start with 'plot' + +""" +# Don't use unicode_literals here (be explicit with u"..." instead) otherwise +# tricky errors come up with exec(code_blocks, ...) calls +from __future__ import division, print_function, absolute_import +from time import time +import ast +import codecs +import hashlib +import os +import re +import shutil +import subprocess +import sys +import traceback +import warnings + + +# Try Python 2 first, otherwise load from Python 3 +from textwrap import dedent +try: + # textwrap indent only exists in python 3 + from textwrap import indent +except ImportError: + def indent(text, prefix, predicate=None): + """Adds 'prefix' to the beginning of selected lines in 'text'. + + If 'predicate' is provided, 'prefix' will only be added to the lines + where 'predicate(line)' is True. If 'predicate' is not provided, + it will default to adding 'prefix' to all non-empty lines that do not + consist solely of whitespace characters. + """ + if predicate is None: + def predicate(line): + return line.strip() + + def prefixed_lines(): + for line in text.splitlines(True): + yield (prefix + line if predicate(line) else line) + return ''.join(prefixed_lines()) + +from io import StringIO + +try: + # make sure that the Agg backend is set before importing any + # matplotlib + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt +except ImportError: + # this script can be imported by nosetest to find tests to run: we should + # not impose the matplotlib requirement in that case. + pass + +from . import glr_path_static +from .backreferences import write_backreferences, _thumbnail_div +from .notebook import Notebook + +try: + basestring +except NameError: + basestring = str + unicode = str + + +############################################################################### + + +class Tee(object): + """A tee object to redirect streams to multiple outputs""" + + def __init__(self, file1, file2): + self.file1 = file1 + self.file2 = file2 + + def write(self, data): + self.file1.write(data) + self.file2.write(data) + + def flush(self): + self.file1.flush() + self.file2.flush() + + +class MixedEncodingStringIO(StringIO): + """Helper when both ASCII and unicode strings will be written""" + def write(self, data): + if not isinstance(data, unicode): + data = data.decode('utf-8') + StringIO.write(self, data) + + +############################################################################### +CODE_DOWNLOAD = """**Total running time of the script:** +({0:.0f} minutes {1:.3f} seconds)\n\n +\n.. container:: sphx-glr-download + + **Download Python source code:** :download:`{2} <{2}>`\n +\n.. container:: sphx-glr-download + + **Download IPython notebook:** :download:`{3} <{3}>`\n""" + +# The following strings are used when we have several pictures: we use +# an html div tag that our CSS uses to turn the lists into horizontal +# lists. +HLIST_HEADER = """ +.. rst-class:: sphx-glr-horizontal + +""" + +HLIST_IMAGE_TEMPLATE = """ + * + + .. image:: /%s + :scale: 47 +""" + +SINGLE_IMAGE = """ +.. image:: /%s + :align: center +""" + + +# This one could contain unicode +CODE_OUTPUT = u""".. rst-class:: sphx-glr-script-out + + Out:: + +{0}\n""" + + +def get_docstring_and_rest(filename): + """Separate `filename` content between docstring and the rest + + Strongly inspired from ast.get_docstring. + + Returns + ------- + docstring: str + docstring of `filename` + rest: str + `filename` content without the docstring + """ + # can't use codecs.open(filename, 'r', 'utf-8') here b/c ast doesn't + # seem to work with unicode strings in Python2.7 + # "SyntaxError: encoding declaration in Unicode string" + with open(filename, 'rb') as f: + content = f.read() + + node = ast.parse(content) + if not isinstance(node, ast.Module): + raise TypeError("This function only supports modules. " + "You provided {0}".format(node.__class__.__name__)) + if node.body and isinstance(node.body[0], ast.Expr) and \ + isinstance(node.body[0].value, ast.Str): + docstring_node = node.body[0] + docstring = docstring_node.value.s + if hasattr(docstring, 'decode'): # python2.7 + docstring = docstring.decode('utf-8') + # This get the content of the file after the docstring last line + # Note: 'maxsplit' argument is not a keyword argument in python2 + rest = content.decode('utf-8').split('\n', docstring_node.lineno)[-1] + return docstring, rest + else: + raise ValueError(('Could not find docstring in file "{0}". ' + 'A docstring is required by sphinx-gallery') + .format(filename)) + + +def split_code_and_text_blocks(source_file): + """Return list with source file separated into code and text blocks. + + Returns + ------- + blocks : list of (label, content) + List where each element is a tuple with the label ('text' or 'code'), + and content string of block. + """ + docstring, rest_of_content = get_docstring_and_rest(source_file) + blocks = [('text', docstring)] + + pattern = re.compile( + r'(?P^#{20,}.*)\s(?P(?:^#.*\s)*)', + flags=re.M) + + pos_so_far = 0 + for match in re.finditer(pattern, rest_of_content): + match_start_pos, match_end_pos = match.span() + code_block_content = rest_of_content[pos_so_far:match_start_pos] + text_content = match.group('text_content') + sub_pat = re.compile('^#', flags=re.M) + text_block_content = dedent(re.sub(sub_pat, '', text_content)).lstrip() + if code_block_content.strip(): + blocks.append(('code', code_block_content)) + if text_block_content.strip(): + blocks.append(('text', text_block_content)) + pos_so_far = match_end_pos + + remaining_content = rest_of_content[pos_so_far:] + if remaining_content.strip(): + blocks.append(('code', remaining_content)) + + return blocks + + +def codestr2rst(codestr, lang='python'): + """Return reStructuredText code block from code string""" + code_directive = "\n.. code-block:: {0}\n\n".format(lang) + indented_block = indent(codestr, ' ' * 4) + return code_directive + indented_block + + +def text2string(content): + """Returns a string without the extra triple quotes""" + try: + return ast.literal_eval(content) + '\n' + except Exception: + return content + '\n' + + +def extract_thumbnail_number(text): + """ Pull out the thumbnail image number specified in the docstring. """ + + # check whether the user has specified a specific thumbnail image + pattr = re.compile("^\s*#\s*sphinx_gallery_thumbnail_number\s*=\s*([0-9]+)\s*$", flags=re.MULTILINE) + match = pattr.search(text) + + if match is None: + # by default, use the first figure created + thumbnail_number = 1 + else: + thumbnail_number = int(match.groups()[0]) + + return thumbnail_number + +def extract_intro(filename): + """ Extract the first paragraph of module-level docstring. max:95 char""" + + docstring, _ = get_docstring_and_rest(filename) + + # lstrip is just in case docstring has a '\n\n' at the beginning + paragraphs = docstring.lstrip().split('\n\n') + if len(paragraphs) > 1: + first_paragraph = re.sub('\n', ' ', paragraphs[1]) + first_paragraph = (first_paragraph[:95] + '...' + if len(first_paragraph) > 95 else first_paragraph) + else: + raise ValueError( + "Example docstring should have a header for the example title " + "and at least a paragraph explaining what the example is about. " + "Please check the example file:\n {}\n".format(filename)) + + return first_paragraph + + +def get_md5sum(src_file): + """Returns md5sum of file""" + + with open(src_file, 'r') as src_data: + src_content = src_data.read() + + # data needs to be encoded in python3 before hashing + if sys.version_info[0] == 3: + src_content = src_content.encode('utf-8') + + src_md5 = hashlib.md5(src_content).hexdigest() + return src_md5 + + +def check_md5sum_change(src_file): + """Returns True if src_file has a different md5sum""" + + src_md5 = get_md5sum(src_file) + + src_md5_file = src_file + '.md5' + src_file_changed = True + if os.path.exists(src_md5_file): + with open(src_md5_file, 'r') as file_checksum: + ref_md5 = file_checksum.read() + if src_md5 == ref_md5: + src_file_changed = False + + if src_file_changed: + with open(src_md5_file, 'w') as file_checksum: + file_checksum.write(src_md5) + + return src_file_changed + + +def _plots_are_current(src_file, image_path): + """Test existence of image file and no change in md5sum of + example""" + + has_image = os.path.exists(image_path) + src_file_changed = check_md5sum_change(src_file) + + return has_image and not src_file_changed + + +def save_figures(image_path, fig_count, gallery_conf): + """Save all open matplotlib figures of the example code-block + + Parameters + ---------- + image_path : str + Path where plots are saved (format string which accepts figure number) + fig_count : int + Previous figure number count. Figure number add from this number + + Returns + ------- + list of strings containing the full path to each figure + """ + figure_list = [] + + fig_managers = matplotlib._pylab_helpers.Gcf.get_all_fig_managers() + for fig_mngr in fig_managers: + # Set the fig_num figure as the current figure as we can't + # save a figure that's not the current figure. + fig = plt.figure(fig_mngr.num) + kwargs = {} + to_rgba = matplotlib.colors.colorConverter.to_rgba + for attr in ['facecolor', 'edgecolor']: + fig_attr = getattr(fig, 'get_' + attr)() + default_attr = matplotlib.rcParams['figure.' + attr] + if to_rgba(fig_attr) != to_rgba(default_attr): + kwargs[attr] = fig_attr + + current_fig = image_path.format(fig_count + fig_mngr.num) + fig.savefig(current_fig, **kwargs) + figure_list.append(current_fig) + + if gallery_conf.get('find_mayavi_figures', False): + from mayavi import mlab + e = mlab.get_engine() + last_matplotlib_fig_num = fig_count + len(figure_list) + total_fig_num = last_matplotlib_fig_num + len(e.scenes) + mayavi_fig_nums = range(last_matplotlib_fig_num + 1, total_fig_num + 1) + + for scene, mayavi_fig_num in zip(e.scenes, mayavi_fig_nums): + current_fig = image_path.format(mayavi_fig_num) + mlab.savefig(current_fig, figure=scene) + # make sure the image is not too large + scale_image(current_fig, current_fig, 850, 999) + figure_list.append(current_fig) + mlab.close(all=True) + + return figure_list + + +def scale_image(in_fname, out_fname, max_width, max_height): + """Scales an image with the same aspect ratio centered in an + image with a given max_width and max_height + if in_fname == out_fname the image can only be scaled down + """ + # local import to avoid testing dependency on PIL: + try: + from PIL import Image + except ImportError: + import Image + img = Image.open(in_fname) + width_in, height_in = img.size + scale_w = max_width / float(width_in) + scale_h = max_height / float(height_in) + + if height_in * scale_w <= max_height: + scale = scale_w + else: + scale = scale_h + + if scale >= 1.0 and in_fname == out_fname: + return + + width_sc = int(round(scale * width_in)) + height_sc = int(round(scale * height_in)) + + # resize the image + img.thumbnail((width_sc, height_sc), Image.ANTIALIAS) + + # insert centered + thumb = Image.new('RGB', (max_width, max_height), (255, 255, 255)) + pos_insert = ((max_width - width_sc) // 2, (max_height - height_sc) // 2) + thumb.paste(img, pos_insert) + + thumb.save(out_fname) + # Use optipng to perform lossless compression on the resized image if + # software is installed + if os.environ.get('SKLEARN_DOC_OPTIPNG', False): + try: + subprocess.call(["optipng", "-quiet", "-o", "9", out_fname]) + except Exception: + warnings.warn('Install optipng to reduce the size of the \ + generated images') + + +def save_thumbnail(thumbnail_image_path, base_image_name, gallery_conf): + """Save the thumbnail image""" + thumb_dir = os.path.join(os.path.dirname(thumbnail_image_path), 'thumb') + if not os.path.exists(thumb_dir): + os.makedirs(thumb_dir) + + thumb_file = os.path.join(thumb_dir, + 'sphx_glr_%s_thumb.png' % base_image_name) + + if os.path.exists(thumbnail_image_path): + scale_image(thumbnail_image_path, thumb_file, 400, 280) + elif not os.path.exists(thumb_file): + # create something to replace the thumbnail + default_thumb_file = os.path.join(glr_path_static(), 'no_image.png') + default_thumb_file = gallery_conf.get("default_thumb_file", + default_thumb_file) + scale_image(default_thumb_file, thumb_file, 200, 140) + + +def generate_dir_rst(src_dir, target_dir, gallery_conf, seen_backrefs): + """Generate the gallery reStructuredText for an example directory""" + if not os.path.exists(os.path.join(src_dir, 'README.txt')): + print(80 * '_') + print('Example directory %s does not have a README.txt file' % + src_dir) + print('Skipping this directory') + print(80 * '_') + return "", [] # because string is an expected return type + + fhindex = open(os.path.join(src_dir, 'README.txt')).read() + if not os.path.exists(target_dir): + os.makedirs(target_dir) + sorted_listdir = [fname for fname in sorted(os.listdir(src_dir)) + if fname.endswith('.py')] + entries_text = [] + computation_times = [] + for fname in sorted_listdir: + amount_of_code, time_elapsed = \ + generate_file_rst(fname, target_dir, src_dir, gallery_conf) + computation_times.append((time_elapsed, fname)) + new_fname = os.path.join(src_dir, fname) + intro = extract_intro(new_fname) + write_backreferences(seen_backrefs, gallery_conf, + target_dir, fname, intro) + this_entry = _thumbnail_div(target_dir, fname, intro) + """ + +.. toctree:: + :hidden: + + /%s/%s\n""" % (target_dir, fname[:-3]) + entries_text.append((amount_of_code, this_entry)) + + # sort to have the smallest entries in the beginning + entries_text.sort() + + for _, entry_text in entries_text: + fhindex += entry_text + + # clear at the end of the section + fhindex += """.. raw:: html\n +
\n\n""" + + return fhindex, computation_times + + +def execute_script(code_block, example_globals, image_path, fig_count, + src_file, gallery_conf): + """Executes the code block of the example file""" + time_elapsed = 0 + stdout = '' + + # We need to execute the code + print('plotting code blocks in %s' % src_file) + + plt.close('all') + cwd = os.getcwd() + # Redirect output to stdout and + orig_stdout = sys.stdout + + try: + # First cd in the original example dir, so that any file + # created by the example get created in this directory + os.chdir(os.path.dirname(src_file)) + my_buffer = MixedEncodingStringIO() + my_stdout = Tee(sys.stdout, my_buffer) + sys.stdout = my_stdout + + t_start = time() + # don't use unicode_literals at the top of this file or you get + # nasty errors here on Py2.7 + exec(code_block, example_globals) + time_elapsed = time() - t_start + + sys.stdout = orig_stdout + + my_stdout = my_buffer.getvalue().strip().expandtabs() + # raise RuntimeError + if my_stdout: + stdout = CODE_OUTPUT.format(indent(my_stdout, u' ' * 4)) + os.chdir(cwd) + figure_list = save_figures(image_path, fig_count, gallery_conf) + + # Depending on whether we have one or more figures, we're using a + # horizontal list or a single rst call to 'image'. + image_list = "" + if len(figure_list) == 1: + figure_name = figure_list[0] + image_list = SINGLE_IMAGE % figure_name.lstrip('/') + elif len(figure_list) > 1: + image_list = HLIST_HEADER + for figure_name in figure_list: + image_list += HLIST_IMAGE_TEMPLATE % figure_name.lstrip('/') + + except Exception: + formatted_exception = traceback.format_exc() + + sys.stdout = orig_stdout # need this here so these lines don't bomb + print(80 * '_') + print('%s is not compiling:' % src_file) + print(formatted_exception) + print(80 * '_') + + figure_list = [] + image_list = codestr2rst(formatted_exception, lang='pytb') + + # Overrides the output thumbnail in the gallery for easy identification + broken_img = os.path.join(glr_path_static(), 'broken_example.png') + shutil.copyfile(broken_img, os.path.join(cwd, image_path.format(1))) + fig_count += 1 # raise count to avoid overwriting image + + # Breaks build on first example error + # XXX This check can break during testing e.g. if you uncomment the + # `raise RuntimeError` by the `my_stdout` call, maybe use `.get()`? + if gallery_conf['abort_on_example_error']: + raise + + finally: + os.chdir(cwd) + sys.stdout = orig_stdout + + print(" - time elapsed : %.2g sec" % time_elapsed) + code_output = u"\n{0}\n\n{1}\n\n".format(image_list, stdout) + + return code_output, time_elapsed, fig_count + len(figure_list) + + +def generate_file_rst(fname, target_dir, src_dir, gallery_conf): + """ Generate the rst file for a given example. + + Returns the amout of code (in characters) of the corresponding + files. + """ + + src_file = os.path.join(src_dir, fname) + example_file = os.path.join(target_dir, fname) + shutil.copyfile(src_file, example_file) + + image_dir = os.path.join(target_dir, 'images') + if not os.path.exists(image_dir): + os.makedirs(image_dir) + + base_image_name = os.path.splitext(fname)[0] + image_fname = 'sphx_glr_' + base_image_name + '_{0:03}.png' + image_path_template = os.path.join(image_dir, image_fname) + + script_blocks = split_code_and_text_blocks(example_file) + + # read specification of the figure to display as thumbnail from main text + _, content = get_docstring_and_rest(example_file) + thumbnail_number = extract_thumbnail_number(content) + + amount_of_code = sum([len(bcontent) + for blabel, bcontent in script_blocks + if blabel == 'code']) + + first_image_path = image_path_template.format(1) + if _plots_are_current(example_file, first_image_path): + return amount_of_code, 0 + + time_elapsed = 0 + + ref_fname = example_file.replace(os.path.sep, '_') + example_rst = """\n\n.. _sphx_glr_{0}:\n\n""".format(ref_fname) + example_nb = Notebook(fname, target_dir) + + filename_pattern = gallery_conf.get('filename_pattern') + if re.search(filename_pattern, src_file) and gallery_conf['plot_gallery']: + example_globals = { + # A lot of examples contains 'print(__doc__)' for example in + # scikit-learn so that running the example prints some useful + # information. Because the docstring has been separated from + # the code blocks in sphinx-gallery, __doc__ is actually + # __builtin__.__doc__ in the execution context and we do not + # want to print it + '__doc__': '', + # Examples may contain if __name__ == '__main__' guards + # for in example scikit-learn if the example uses multiprocessing + '__name__': '__main__'} + + fig_count = 0 + # A simple example has two blocks: one for the + # example introduction/explanation and one for the code + is_example_notebook_like = len(script_blocks) > 2 + for blabel, bcontent in script_blocks: + if blabel == 'code': + code_output, rtime, fig_count = execute_script(bcontent, + example_globals, + image_path_template, + fig_count, + src_file, + gallery_conf) + + time_elapsed += rtime + example_nb.add_code_cell(bcontent) + + if is_example_notebook_like: + example_rst += codestr2rst(bcontent) + '\n' + example_rst += code_output + else: + example_rst += code_output + if 'sphx-glr-script-out' in code_output: + # Add some vertical space after output + example_rst += "\n\n|\n\n" + example_rst += codestr2rst(bcontent) + '\n' + + else: + example_rst += text2string(bcontent) + '\n' + example_nb.add_markdown_cell(text2string(bcontent)) + else: + for blabel, bcontent in script_blocks: + if blabel == 'code': + example_rst += codestr2rst(bcontent) + '\n' + example_nb.add_code_cell(bcontent) + else: + example_rst += bcontent + '\n' + example_nb.add_markdown_cell(text2string(bcontent)) + + thumbnail_image_path = image_path_template.format(thumbnail_number) + save_thumbnail(thumbnail_image_path, base_image_name, gallery_conf) + + time_m, time_s = divmod(time_elapsed, 60) + example_nb.save_file() + with codecs.open(os.path.join(target_dir, base_image_name + '.rst'), + mode='w', encoding='utf-8') as f: + example_rst += CODE_DOWNLOAD.format(time_m, time_s, fname, + example_nb.file_name) + f.write(example_rst) + + return amount_of_code, time_elapsed diff --git a/doc/ext/sphinx_gallery/notebook.py b/doc/ext/sphinx_gallery/notebook.py new file mode 100644 index 00000000..c0ee5cd8 --- /dev/null +++ b/doc/ext/sphinx_gallery/notebook.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +r""" +============================ +Parser for Jupyter notebooks +============================ + +Class that holds the Ipython notebook information + +""" +# Author: Óscar Nájera +# License: 3-clause BSD + +from __future__ import division, absolute_import, print_function +import json +import os +import re +import sys + +def ipy_notebook_skeleton(): + """Returns a dictionary with the elements of a Jupyter notebook""" + py_version = sys.version_info + notebook_skeleton = { + "cells": [], + "metadata": { + "kernelspec": { + "display_name": "Python " + str(py_version[0]), + "language": "python", + "name": "python" + str(py_version[0]) + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": py_version[0] + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython" + str(py_version[0]), + "version": '{0}.{1}.{2}'.format(*sys.version_info[:3]) + } + }, + "nbformat": 4, + "nbformat_minor": 0 + } + return notebook_skeleton + + +def rst2md(text): + """Converts the RST text from the examples docstrigs and comments + into markdown text for the IPython notebooks""" + + top_heading = re.compile(r'^=+$\s^([\w\s-]+)^=+$', flags=re.M) + text = re.sub(top_heading, r'# \1', text) + + math_eq = re.compile(r'^\.\. math::((?:.+)?(?:\n+^ .+)*)', flags=re.M) + text = re.sub(math_eq, + lambda match: r'$${0}$$'.format(match.group(1).strip()), + text) + inline_math = re.compile(r':math:`(.+)`') + text = re.sub(inline_math, r'$\1$', text) + + return text + + +class Notebook(object): + """Ipython notebook object + + Constructs the file cell-by-cell and writes it at the end""" + + def __init__(self, file_name, target_dir): + """Declare the skeleton of the notebook + + Parameters + ---------- + file_name : str + original script file name, .py extension will be renamed + target_dir: str + directory where notebook file is to be saved + """ + + self.file_name = file_name.replace('.py', '.ipynb') + self.write_file = os.path.join(target_dir, self.file_name) + self.work_notebook = ipy_notebook_skeleton() + self.add_code_cell("%matplotlib inline") + + def add_code_cell(self, code): + """Add a code cell to the notebook + + Parameters + ---------- + code : str + Cell content + """ + + code_cell = { + "cell_type": "code", + "execution_count": None, + "metadata": {"collapsed": False}, + "outputs": [], + "source": [code.strip()] + } + self.work_notebook["cells"].append(code_cell) + + def add_markdown_cell(self, text): + """Add a markdown cell to the notebook + + Parameters + ---------- + code : str + Cell content + """ + markdown_cell = { + "cell_type": "markdown", + "metadata": {}, + "source": [rst2md(text)] + } + self.work_notebook["cells"].append(markdown_cell) + + def save_file(self): + """Saves the notebook to a file""" + with open(self.write_file, 'w') as out_nb: + json.dump(self.work_notebook, out_nb, indent=2)