# -*- coding: utf-8 -*-
# Author: Óscar Nájera
# License: 3-clause BSD
"""
Backreferences Generator
========================
Parses example file code in order to keep track of used functions
"""
from __future__ import print_function, unicode_literals
import ast
import codecs
import collections
from html import escape
import inspect
import os
import re
import warnings
from sphinx.errors import ExtensionError
import sphinx.util
from .scrapers import _find_image_ext
from .utils import _replace_md5
THUMBNAIL_PARENT_DIV = """
.. raw:: html
<div class="sphx-glr-thumbnails">
"""
THUMBNAIL_PARENT_DIV_CLOSE = """
.. raw:: html
</div>
"""
[docs]class DummyClass(object):
"""Dummy class for testing method resolution."""
[docs] def run(self):
"""Do nothing."""
pass
@property
def prop(self):
"""Property."""
return 'Property'
[docs]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, global_variables=None):
super(NameFinder, self).__init__()
self.imported_names = {}
self.global_variables = global_variables or {}
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):
options = list()
for name in self.accessed_names:
local_name_split = name.split('.')
# first pass: by global variables and object inspection (preferred)
for split_level in range(len(local_name_split)):
local_name = '.'.join(local_name_split[:split_level + 1])
remainder = name[len(local_name):]
if local_name in self.global_variables:
obj = self.global_variables[local_name]
class_attr, method = False, []
if remainder:
for level in remainder[1:].split('.'):
last_obj = obj
# determine if it's a property
prop = getattr(last_obj.__class__, level, None)
if isinstance(prop, property):
obj = last_obj
class_attr, method = True, [level]
break
# For most objects this will emit a AttributeError,
# but for some (e.g., PyQt5) you can get other
# errors like "RuntimeError: wrapped C/C++ object
# ... has been deleted" so let's be safer with
# plain Exception
try:
obj = getattr(obj, level)
except Exception:
break
if inspect.ismethod(obj):
obj = last_obj
class_attr, method = True, [level]
break
del remainder
is_class = inspect.isclass(obj)
if is_class or class_attr:
# Traverse all bases
classes = [obj if is_class else obj.__class__]
offset = 0
while offset < len(classes):
for base in classes[offset].__bases__:
# "object" as a base class is not very useful
if base not in classes and base is not object:
classes.append(base)
offset += 1
else:
classes = [obj.__class__]
for cc in classes:
module = inspect.getmodule(cc)
if module is not None:
module = module.__name__.split('.')
class_name = cc.__qualname__
# a.b.C.meth could be documented as a.C.meth,
# so go down the list
for depth in range(len(module), 0, -1):
full_name = '.'.join(
module[:depth] + [class_name] + method)
options.append(
(name, full_name, class_attr, is_class,
False))
# second pass: by import (can't resolve as well without doing
# some actions like actually importing the modules, so use it
# as a last resort)
for split_level in range(len(local_name_split)):
local_name = '.'.join(local_name_split[:split_level + 1])
remainder = name[len(local_name):]
if local_name in self.imported_names:
full_name = self.imported_names[local_name] + remainder
is_class = class_attr = False # can't tell without import
options.append(
(name, full_name, class_attr, is_class, False))
return options
def _from_import(a, b):
imp_line = 'from %s import %s' % (a, b)
scope = dict()
with warnings.catch_warnings(record=True): # swallow warnings
warnings.simplefilter('ignore')
exec(imp_line, scope, scope)
return scope
def _get_short_module_name(module_name, obj_name):
"""Get the shortest possible module name."""
if '.' in obj_name:
obj_name, attr = obj_name.split('.')
else:
attr = None
scope = {}
try:
# Find out what the real object is supposed to be.
scope = _from_import(module_name, obj_name)
except Exception: # wrong object
return None
else:
real_obj = scope[obj_name]
if attr is not None and not hasattr(real_obj, attr): # wrong class
return None # wrong object
parts = module_name.split('.')
short_name = module_name
for i in range(len(parts) - 1, 0, -1):
short_name = '.'.join(parts[:i])
scope = {}
try:
scope = _from_import(short_name, obj_name)
# Ensure shortened object is the same as what we expect.
assert real_obj is scope[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
# keep in synch w/ configuration.rst "Add mini-galleries for API documentation"
_regex = re.compile(r':(?:'
r'func|'
r'meth|'
r'attr|'
r'obj|'
r'class):`~?(\S*)`'
)
[docs]def identify_names(script_blocks, global_variables=None, node=''):
"""Build a codeobj summary by identifying and resolving used names."""
if node == '': # mostly convenience for testing functions
c = '\n'.join(txt for kind, txt, _ in script_blocks if kind == 'code')
node = ast.parse(c)
# Get matches from the code (AST, implicit matches)
finder = NameFinder(global_variables)
if node is not None:
finder.visit(node)
names = list(finder.get_mapping())
# Get matches from docstring inspection (explicit matches)
text = '\n'.join(txt for kind, txt, _ in script_blocks if kind == 'text')
names.extend((x, x, False, False, True) for x in re.findall(_regex, text))
example_code_obj = collections.OrderedDict() # order is important
# Make a list of all guesses, in `_embed_code_links` we will break
# when we find a match
for name, full_name, class_like, is_class, is_explicit in names:
if name not in example_code_obj:
example_code_obj[name] = list()
# name is as written in file (e.g. np.asarray)
# full_name includes resolved import path (e.g. numpy.asarray)
splitted = full_name.rsplit('.', 1 + class_like)
if len(splitted) == 1:
splitted = ('builtins', splitted[0])
elif len(splitted) == 3: # class-like
assert class_like
splitted = (splitted[0], '.'.join(splitted[1:]))
else:
assert not class_like
module, attribute = splitted
# get shortened module name
module_short = _get_short_module_name(module, attribute)
cobj = {'name': attribute, 'module': module,
'module_short': module_short or module, 'is_class': is_class,
'is_explicit': is_explicit}
example_code_obj[name].append(cobj)
return example_code_obj
THUMBNAIL_TEMPLATE = """
.. raw:: html
<div class="sphx-glr-thumbcontainer" tooltip="{snippet}">
.. only:: html
.. image:: /{thumbnail}
:alt:
:ref:`sphx_glr_{ref_name}`
.. raw:: html
<div class="sphx-glr-thumbnail-title">{title}</div>
</div>
"""
BACKREF_THUMBNAIL_TEMPLATE = THUMBNAIL_TEMPLATE + """
.. only:: not html
* :ref:`sphx_glr_{ref_name}`
"""
def _thumbnail_div(target_dir, src_dir, fname, snippet, title,
is_backref=False, check=True):
"""Generate RST to place a thumbnail in a gallery."""
thumb, _ = _find_image_ext(
os.path.join(target_dir, 'images', 'thumb',
'sphx_glr_%s_thumb.png' % fname[:-3]))
if check and not os.path.isfile(thumb):
# This means we have done something wrong in creating our thumbnail!
raise ExtensionError('Could not find internal Sphinx-Gallery thumbnail'
' file:\n%s' % (thumb,))
thumb = os.path.relpath(thumb, src_dir)
full_dir = os.path.relpath(target_dir, src_dir)
# Inside rst files forward slash defines paths
thumb = thumb.replace(os.sep, "/")
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=escape(snippet),
thumbnail=thumb, title=title, ref_name=ref_name)
def _write_backreferences(backrefs, seen_backrefs, gallery_conf,
target_dir, fname, snippet, title):
"""Write backreference file including a thumbnail list of examples."""
if gallery_conf['backreferences_dir'] is None:
return
for backref in backrefs:
include_path = os.path.join(gallery_conf['src_dir'],
gallery_conf['backreferences_dir'],
'%s.examples.new' % backref)
seen = backref in seen_backrefs
with codecs.open(include_path, 'a' if seen else 'w',
encoding='utf-8') as ex_file:
if not seen:
# Be aware that if the number of lines of this heading changes,
# the minigallery directive should be modified accordingly
heading = 'Examples using ``%s``' % backref
ex_file.write('\n\n' + heading + '\n')
ex_file.write('^' * len(heading) + '\n')
ex_file.write('\n\n.. start-sphx-glr-thumbnails\n\n')
# Open a div which will contain all thumbnails
# (it will be closed in _finalize_backreferences)
ex_file.write(THUMBNAIL_PARENT_DIV)
ex_file.write(_thumbnail_div(target_dir, gallery_conf['src_dir'],
fname, snippet, title,
is_backref=True))
seen_backrefs.add(backref)
def _finalize_backreferences(seen_backrefs, gallery_conf):
"""Replace backref files only if necessary."""
logger = sphinx.util.logging.getLogger('sphinx-gallery')
if gallery_conf['backreferences_dir'] is None:
return
for backref in seen_backrefs:
path = os.path.join(gallery_conf['src_dir'],
gallery_conf['backreferences_dir'],
'%s.examples.new' % backref)
if os.path.isfile(path):
# Close div containing all thumbnails
# (it was open in _write_backreferences)
with codecs.open(path, 'a', encoding='utf-8') as ex_file:
ex_file.write(THUMBNAIL_PARENT_DIV_CLOSE)
_replace_md5(path, mode='t')
else:
level = gallery_conf['log_level'].get('backreference_missing',
'warning')
func = getattr(logger, level)
func('Could not find backreferences file: %s' % (path,))
func('The backreferences are likely to be erroneous '
'due to file system case insensitivity.')