Coverage for src/mkdocs_gallery/backreferences.py: 95%
185 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-09-30 08:26 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2024-09-30 08:26 +0000
1# Authors: Sylvain MARIE <sylvain.marie@se.com>
2# + All contributors to <https://github.com/smarie/mkdocs-gallery>
3#
4# Original idea and code: sphinx-gallery, <https://sphinx-gallery.github.io>
5# License: 3-clause BSD, <https://github.com/smarie/mkdocs-gallery/blob/master/LICENSE>
6"""
7Backreferences Generator
8========================
10Parses example file code in order to keep track of used functions
11"""
12from __future__ import print_function, unicode_literals
14import ast
15import codecs
16import collections
17import inspect
18import os
19import re
20import warnings
21from html import escape
22from importlib import import_module
23from typing import Set
25from . import mkdocs_compatibility
26from .errors import ExtensionError
27from .gen_data_model import AllInformation, GalleryScriptResults
28from .utils import _new_file, _replace_by_new_if_needed
31class DummyClass(object):
32 """Dummy class for testing method resolution."""
34 def run(self):
35 """Do nothing."""
36 pass
38 @property
39 def prop(self):
40 """Property."""
41 return "Property"
44class NameFinder(ast.NodeVisitor):
45 """Finds the longest form of variable names and their imports in code.
47 Only retains names from imported modules.
48 """
50 def __init__(self, global_variables=None):
51 super(NameFinder, self).__init__()
52 self.imported_names = {}
53 self.global_variables = global_variables or {}
54 self.accessed_names = set()
56 def visit_Import(self, node, prefix=""):
57 for alias in node.names:
58 local_name = alias.asname or alias.name
59 self.imported_names[local_name] = prefix + alias.name
61 def visit_ImportFrom(self, node):
62 self.visit_Import(node, node.module + ".")
64 def visit_Name(self, node):
65 self.accessed_names.add(node.id)
67 def visit_Attribute(self, node):
68 attrs = []
69 while isinstance(node, ast.Attribute):
70 attrs.append(node.attr)
71 node = node.value
73 if isinstance(node, ast.Name):
74 # This is a.b, not e.g. a().b
75 attrs.append(node.id)
76 self.accessed_names.add(".".join(reversed(attrs)))
77 else:
78 # need to get a in a().b
79 self.visit(node)
81 def get_mapping(self):
82 options = list()
83 for name in self.accessed_names:
84 local_name_split = name.split(".")
85 # first pass: by global variables and object inspection (preferred)
86 for split_level in range(len(local_name_split)):
87 local_name = ".".join(local_name_split[: split_level + 1])
88 remainder = name[len(local_name) :]
89 if local_name in self.global_variables:
90 obj = self.global_variables[local_name]
91 class_attr, method = False, []
92 if remainder:
93 for level in remainder[1:].split("."):
94 last_obj = obj
95 # determine if it's a property
96 prop = getattr(last_obj.__class__, level, None)
97 if isinstance(prop, property):
98 obj = last_obj
99 class_attr, method = True, [level]
100 break
101 try:
102 obj = getattr(obj, level)
103 except AttributeError:
104 break
105 if inspect.ismethod(obj):
106 obj = last_obj
107 class_attr, method = True, [level]
108 break
109 del remainder
110 is_class = inspect.isclass(obj)
111 if is_class or class_attr:
112 # Traverse all bases
113 classes = [obj if is_class else obj.__class__]
114 offset = 0
115 while offset < len(classes):
116 for base in classes[offset].__bases__:
117 # "object" as a base class is not very useful
118 if base not in classes and base is not object:
119 classes.append(base)
120 offset += 1
121 else:
122 classes = [obj.__class__]
123 for cc in classes:
124 module = inspect.getmodule(cc)
125 if module is not None: 125 ↛ 123line 125 didn't jump to line 123 because the condition on line 125 was always true
126 module = module.__name__.split(".")
127 class_name = cc.__qualname__
128 # a.b.C.meth could be documented as a.C.meth,
129 # so go down the list
130 for depth in range(len(module), 0, -1):
131 full_name = ".".join(module[:depth] + [class_name] + method)
132 options.append((name, full_name, class_attr, is_class))
133 # second pass: by import (can't resolve as well without doing
134 # some actions like actually importing the modules, so use it
135 # as a last resort)
136 for split_level in range(len(local_name_split)):
137 local_name = ".".join(local_name_split[: split_level + 1])
138 remainder = name[len(local_name) :]
139 if local_name in self.imported_names:
140 full_name = self.imported_names[local_name] + remainder
141 is_class = class_attr = False # can't tell without import
142 options.append((name, full_name, class_attr, is_class))
143 return options
146def _from_import(a, b):
147 # imp_line = 'from %s import %s' % (a, b)
148 # scope = dict()
149 # with warnings.catch_warnings(record=True): # swallow warnings
150 # warnings.simplefilter('ignore')
151 # exec(imp_line, scope, scope)
152 # return scope
153 with warnings.catch_warnings(record=True): # swallow warnings
154 warnings.simplefilter("ignore")
155 m = import_module(a)
156 obj = getattr(m, b)
158 return obj
161def _get_short_module_name(module_name, obj_name):
162 """Get the shortest possible module name."""
163 if "." in obj_name:
164 obj_name, attr = obj_name.split(".")
165 else:
166 attr = None
167 # scope = {}
168 try:
169 # Find out what the real object is supposed to be.
170 imported_obj = _from_import(module_name, obj_name)
171 except Exception: # wrong object
172 return None
173 else:
174 real_obj = imported_obj
175 if attr is not None and not hasattr(real_obj, attr): # wrong class
176 return None # wrong object
178 parts = module_name.split(".")
179 short_name = module_name
180 for i in range(len(parts) - 1, 0, -1):
181 short_name = ".".join(parts[:i])
182 # scope = {}
183 try:
184 imported_obj = _from_import(short_name, obj_name)
185 # Ensure shortened object is the same as what we expect.
186 assert real_obj is imported_obj # noqa
187 except Exception: # libraries can throw all sorts of exceptions...
188 # get the last working module name
189 short_name = ".".join(parts[: (i + 1)])
190 break
191 return short_name
194_regex = re.compile(r":(?:" r"func(?:tion)?|" r"meth(?:od)?|" r"attr(?:ibute)?|" r"obj(?:ect)?|" r"class):`~?(\S*)`")
197def identify_names(script_blocks, global_variables=None, node=""):
198 """Build a codeobj summary by identifying and resolving used names."""
200 if node == "": # mostly convenience for testing functions
201 c = "\n".join(txt for kind, txt, _ in script_blocks if kind == "code")
202 node = ast.parse(c)
204 # Get matches from the code (AST)
205 finder = NameFinder(global_variables)
206 if node is not None:
207 finder.visit(node)
208 names = list(finder.get_mapping())
210 # Get matches from docstring inspection
211 text = "\n".join(txt for kind, txt, _ in script_blocks if kind == "text")
212 names.extend((x, x, False, False) for x in re.findall(_regex, text))
213 example_code_obj = collections.OrderedDict() # order is important
215 # Make a list of all guesses, in `_embed_code_links` we will break when we find a match
216 for name, full_name, class_like, is_class in names:
217 if name not in example_code_obj:
218 example_code_obj[name] = list()
220 # name is as written in file (e.g. np.asarray)
221 # full_name includes resolved import path (e.g. numpy.asarray)
222 splitted = full_name.rsplit(".", 1 + class_like)
223 if len(splitted) == 1:
224 splitted = ("builtins", splitted[0])
225 elif len(splitted) == 3: # class-like
226 assert class_like # noqa
227 splitted = (splitted[0], ".".join(splitted[1:]))
228 else:
229 assert not class_like # noqa
231 module, attribute = splitted
233 # get shortened module name
234 module_short = _get_short_module_name(module, attribute)
235 cobj = {
236 "name": attribute,
237 "module": module,
238 "module_short": module_short or module,
239 "is_class": is_class,
240 }
242 example_code_obj[name].append(cobj)
244 return example_code_obj
247# TODO only:: html ?
248THUMBNAIL_TEMPLATE = """
249<div class="mkd-glr-thumbcontainer" tooltip="{snippet}">
250 <!--div class="figure align-default" id="id1"-->
251 <img alt="{title}" src="{thumbnail}" />
252 <p class="caption">
253 <span class="caption-text">
254 <a class="reference internal" href="{example_html}">
255 <span class="std std-ref">{title}</span>
256 </a>
257 </span>
258 <!--a class="headerlink" href="#id1" title="Permalink to this image"></a-->
259 </p>
260 <!--/div-->
261</div>
262"""
264# TODO something specific here ?
265BACKREF_THUMBNAIL_TEMPLATE = THUMBNAIL_TEMPLATE
266# + """
267# .. only:: not html
268#
269# * :ref:`mkd_glr_{ref_name}`
270# """
273def _thumbnail_div(script_results: GalleryScriptResults, is_backref: bool = False, check: bool = True):
274 """
275 Generate MD to place a thumbnail in a gallery.
277 Parameters
278 ----------
279 script_results : GalleryScriptResults
280 The results from processing a gallery example
282 is_backref : bool
283 ?
285 check : bool
286 ?
288 Returns
289 -------
290 md : str
291 The markdown to integrate in the global gallery readme. Note that this is also the case for subsections.
292 """
293 # Absolute path to the thumbnail
294 if check and not script_results.thumb.exists(): 294 ↛ 296line 294 didn't jump to line 296 because the condition on line 294 was never true
295 # This means we have done something wrong in creating our thumbnail!
296 raise ExtensionError(f"Could not find internal mkdocs-gallery thumbnail file:\n{script_results.thumb}")
298 # Relative path to the thumbnail (relative to the gallery, not the subsection)
299 thumb = script_results.thumb_rel_root_gallery
301 # Relative path to the html tutorial that will be generated from the md
302 example_html = script_results.script.md_file_rel_root_gallery.with_suffix("")
304 template = BACKREF_THUMBNAIL_TEMPLATE if is_backref else THUMBNAIL_TEMPLATE
305 return template.format(
306 snippet=escape(script_results.intro),
307 thumbnail=thumb,
308 title=script_results.script.title,
309 example_html=example_html,
310 )
313def _write_backreferences(backrefs: Set, seen_backrefs: Set, script_results: GalleryScriptResults):
314 """
315 Write backreference file including a thumbnail list of examples.
317 Parameters
318 ----------
319 backrefs : set
321 seen_backrefs : set
323 script_results : GalleryScriptResults
325 Returns
326 -------
328 """
329 all_info = script_results.script.gallery.all_info
331 for backref in backrefs:
332 # Get the backref file to use for this module, according to config
333 include_path = _new_file(all_info.get_backreferences_file(backref))
335 # Create new or append to existing file
336 seen = backref in seen_backrefs
337 with codecs.open(str(include_path), "a" if seen else "w", encoding="utf-8") as ex_file:
338 # If first ref: write header
339 if not seen:
340 # Be aware that if the number of lines of this heading changes,
341 # the minigallery directive should be modified accordingly
342 heading = "Examples using ``%s``" % backref
343 ex_file.write("\n\n" + heading + "\n")
344 ex_file.write("^" * len(heading) + "\n")
346 # Write the thumbnail
347 ex_file.write(_thumbnail_div(script_results, is_backref=True))
348 seen_backrefs.add(backref)
351def _finalize_backreferences(seen_backrefs, all_info: AllInformation):
352 """Replace backref files only if necessary."""
353 logger = mkdocs_compatibility.getLogger("mkdocs-gallery")
354 if all_info.gallery_conf["backreferences_dir"] is None: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true
355 return
357 for backref in seen_backrefs:
358 # Get the backref file to use for this module, according to config
359 path = _new_file(all_info.get_backreferences_file(backref))
360 if path.exists(): 360 ↛ 365line 360 didn't jump to line 365 because the condition on line 360 was always true
361 # Simply drop the .new suffix
362 _replace_by_new_if_needed(path, md5_mode="t")
363 else:
364 # No file: warn
365 level = all_info.gallery_conf["log_level"].get("backreference_missing", "warning")
366 func = getattr(logger, level)
367 func("Could not find backreferences file: %s" % (path,))
368 func("The backreferences are likely to be erroneous " "due to file system case insensitivity.")