Coverage for src/mkdocs_gallery/plugin.py: 72%
186 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"""
7The mkdocs plugin entry point
8"""
9import os
10import platform
11import re
12from os.path import relpath
13from pathlib import Path
14from typing import Any, Dict, List, Union
16from mkdocs import __version__ as mkdocs_version_str
17from mkdocs.config import config_options as co
18from mkdocs.config.base import Config, ValidationError
19from mkdocs.exceptions import ConfigurationError
20from mkdocs.plugins import BasePlugin
21from mkdocs.structure.files import Files
22from mkdocs.structure.pages import Page
23from packaging import version
24from packaging.version import parse as parse_version
26from . import glr_path_static
27from .binder import copy_binder_files
29# from .docs_resolv import embed_code_links
30from .gen_gallery import fill_mkdocs_nav, generate_gallery_md, parse_config, summarize_failing_examples
31from .utils import is_relative_to
33IS_PY37 = parse_version("3.7") <= parse_version(platform.python_version()) < parse_version("3.8")
35mkdocs_version = version.parse(mkdocs_version_str)
36is_mkdocs_14_or_greater = mkdocs_version >= version.parse("1.4")
39class ConfigList(co.OptionallyRequired):
40 """A list or single element of configuration matching a specific ConfigOption"""
42 def __init__(
43 self,
44 item_config: co.BaseConfigOption,
45 single_elt_allowed: bool = True,
46 **kwargs,
47 ):
48 super().__init__(**kwargs)
49 self.single_elt_allowed = single_elt_allowed
50 self.item_config = item_config
52 def run_validation(self, value):
53 if not isinstance(value, (list, tuple)):
54 if self.single_elt_allowed: 54 ↛ 57line 54 didn't jump to line 57 because the condition on line 54 was always true
55 value = (value,)
56 else:
57 msg = f"Expected a list but received a single element: {value}."
58 raise ValidationError(msg)
60 # Validate all elements in the list
61 result = []
62 for i, v in enumerate(value):
63 try:
64 result.append(self.item_config.validate(v))
65 except ValidationError as e:
66 raise ValidationError(f"Error validating config item #{i+1}: {e}")
67 return result
70class Dir(co.Dir):
71 """mkdocs.config.config_options.Dir replacement: returns a pathlib object instead of a string"""
73 def run_validation(self, value):
74 return Path(co.Dir.run_validation(self, value))
77class File(co.File):
78 """mkdocs.config.config_options.File replacement: returns a pathlib object instead of a string"""
80 def run_validation(self, value):
81 return Path(co.File.run_validation(self, value))
84# Binder configuration is a "sub config". This has changed in mkdocs 1.4, handling both here.
85if is_mkdocs_14_or_greater: 85 ↛ 107line 85 didn't jump to line 107 because the condition on line 85 was always true
86 # Construct a class where class attributes are the config options
87 class _BinderOptions(co.Config):
88 # Required keys
89 org = co.Type(str)
90 repo = co.Type(str)
91 dependencies = ConfigList(File(exists=True))
92 # Optional keys
93 branch = co.Type(str, default="gh-pages")
94 binderhub_url = co.URL(default="https://mybinder.org")
95 filepath_prefix = co.Optional(co.Type(str)) # default is None
96 notebooks_dir = co.Optional(co.Type(str)) # default is "notebooks"
97 use_jupyter_lab = co.Optional(co.Type(bool)) # default is False
99 def create_binder_config():
100 opt = co.SubConfig(_BinderOptions)
101 # Set the default value to None so that it is not (invalid) empty dict anymore. GH#45
102 opt.default = None
103 return opt
105else:
106 # Use MySubConfig
107 class MySubConfig(co.SubConfig):
108 """Same as SubConfig except that it will be an empty dict when nothing is provided by user,
109 instead of a dict with all options containing their default values."""
111 def validate(self, value):
112 if value is None or len(value) == 0:
113 return None
114 else:
115 return super(MySubConfig, self).validate(value)
117 def run_validation(self, value):
118 """Fix SubConfig: errors and warnings were not caught
120 See https://github.com/mkdocs/mkdocs/pull/2710
121 """
122 failed, self.warnings = Config.validate(self)
123 if len(failed) > 0:
124 # get the first failing one
125 key, err = failed[0]
126 raise ConfigurationError(f"Sub-option {key!r} configuration error: {err}")
128 return self
130 def create_binder_config():
131 return MySubConfig(
132 # Required keys
133 ("org", co.Type(str, required=True)),
134 ("repo", co.Type(str, required=True)),
135 ("dependencies", ConfigList(File(exists=True), required=True)),
136 # Optional keys
137 ("branch", co.Type(str, required=True, default="gh-pages")),
138 ("binderhub_url", co.URL(required=True, default="https://mybinder.org")),
139 ("filepath_prefix", co.Type(str)), # default is None
140 ("notebooks_dir", co.Type(str)), # default is "notebooks"
141 ("use_jupyter_lab", co.Type(bool)), # default is False
142 )
145class GalleryPlugin(BasePlugin):
146 # # Mandatory to display plotly graph within the site
147 # import plotly.io as pio
148 # pio.renderers.default = "sphinx_gallery"
150 config_scheme = (
151 ("conf_script", File(exists=True)),
152 ("filename_pattern", co.Type(str)),
153 ("ignore_pattern", co.Type(str)),
154 ("examples_dirs", ConfigList(Dir(exists=True))),
155 # 'reset_argv': DefaultResetArgv(),
156 ("subsection_order", co.Choice(choices=(None, "ExplicitOrder"))),
157 (
158 "within_subsection_order",
159 co.Choice(choices=("FileNameSortKey", "NumberOfCodeLinesSortKey")),
160 ),
161 ("gallery_dirs", ConfigList(Dir(exists=False))),
162 ("backreferences_dir", Dir(exists=False)),
163 ("doc_module", ConfigList(co.Type(str))),
164 # 'reference_url': {}, TODO how to link code to external functions?
165 ("capture_repr", ConfigList(co.Type(str))),
166 ("ignore_repr_types", co.Type(str)),
167 # Build options
168 ("plot_gallery", co.Type(bool)),
169 ("download_all_examples", co.Type(bool)),
170 ("abort_on_example_error", co.Type(bool)),
171 ("only_warn_on_example_error", co.Type(bool)),
172 # 'failing_examples': {}, # type: Set[str]
173 # 'passing_examples': [],
174 # 'stale_examples': [],
175 ("run_stale_examples", co.Type(bool)),
176 ("expected_failing_examples", ConfigList(File(exists=True))),
177 ("thumbnail_size", ConfigList(co.Type(int), single_elt_allowed=False)),
178 ("min_reported_time", co.Type(int)),
179 ("binder", co.Optional(create_binder_config())),
180 ("image_scrapers", ConfigList(co.Type(str))),
181 ("compress_images", ConfigList(co.Type(str))),
182 ("reset_modules", ConfigList(co.Type(str))),
183 ("first_notebook_cell", co.Type(str)),
184 ("last_notebook_cell", co.Type(str)),
185 ("notebook_images", co.Type(bool)),
186 # # 'pypandoc': False,
187 ("remove_config_comments", co.Type(bool)),
188 ("show_memory", co.Type(bool)),
189 ("show_signature", co.Type(bool)),
190 # 'junit': '',
191 # 'log_level': {'backreference_missing': 'warning'},
192 ("inspect_global_variables", co.Type(bool)),
193 # 'css': _KNOWN_CSS,
194 ("matplotlib_animations", co.Type(bool)),
195 ("image_srcset", ConfigList(co.Type(str))),
196 ("default_thumb_file", File(exists=True)),
197 ("line_numbers", co.Type(bool)),
198 )
200 def on_config(self, config, **kwargs):
201 """
202 TODO Add plugin templates and scripts to config.
203 """
205 from mkdocs.utils import yaml_load
207 # Enable navigation indexes in "material" theme,
208 # see https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/#section-index-pages
209 if config["theme"].name == "material":
210 if "navigation.indexes" not in config["theme"]["features"]: 210 ↛ 215line 210 didn't jump to line 215 because the condition on line 210 was always true
211 if "toc.integrate" not in config["theme"]["features"]: 211 ↛ 215line 211 didn't jump to line 215 because the condition on line 211 was always true
212 config["theme"]["features"].append("navigation.indexes")
214 # Set Python version dependent emoji logic for backward compatibility
215 mx_name = "materialx" if IS_PY37 else "material.extensions"
217 extra_config_yml = f"""
218markdown_extensions:
219 # to declare attributes such as css classes on markdown elements. For example to change the color
220 - attr_list
222 # to add notes such as http://squidfunk.github.io/mkdocs-material/extensions/admonition/
223 - admonition
225 # to display the code blocks https://squidfunk.github.io/mkdocs-material/reference/code-blocks/
226 - pymdownx.highlight
227 - pymdownx.inlinehilite
228 - pymdownx.details
229 - pymdownx.superfences
230 - pymdownx.snippets
232 # to have the download icons in the buttons
233 - pymdownx.emoji:
234 emoji_index: !!python/name:{mx_name}.emoji.twemoji
235 emoji_generator: !!python/name:{mx_name}.emoji.to_svg
238"""
239 extra_config = yaml_load(extra_config_yml)
240 merge_extra_config(extra_config, config)
242 # Append static resources
243 static_resources_dir = glr_path_static()
244 config["theme"].dirs.append(static_resources_dir)
245 for css_file in os.listdir(static_resources_dir):
246 if css_file.endswith(".css"):
247 config["extra_css"].append(css_file)
248 # config['theme'].static_templates.add('search.html')
249 # config['extra_javascript'].append('search/main.js')
251 # Handle our custom class - convert to a dict
252 if self.config["binder"]:
253 self.config["binder"] = dict(self.config["binder"])
255 # Remember the conf script location for later (for excluding files in `on_files` below)
256 self.conf_script = self.config["conf_script"]
258 # Use almost the original sphinx-gallery config validator
259 self.config = parse_config(self.config, mkdocs_conf=config)
261 # TODO do we need to register those CSS files and how ? (they are already registered ads
262 # for css in self.config['css']:
263 # if css not in _KNOWN_CSS:
264 # raise ConfigError('Unknown css %r, must be one of %r'
265 # % (css, _KNOWN_CSS))
266 # if gallery_conf['app'] is not None: # can be None in testing
267 # gallery_conf['app'].add_css_file(css + '.css')
269 return config
271 def on_pre_build(self, config, **kwargs):
272 """Create one md file for each python example in the gallery, and update the navigation."""
274 # TODO ?
275 # if 'sphinx.ext.autodoc' in app.extensions:
276 # app.connect('autodoc-process-docstring', touch_empty_backreferences)
277 # app.add_directive('minigallery', MiniGallery)
278 # app.add_directive("image-sg", ImageSg)
279 # imagesg_addnode(app)
281 galleries_tocs, self.md_to_src = generate_gallery_md(self.config, config)
283 # Update the nav for all galleries if needed
284 new_nav = fill_mkdocs_nav(config, galleries_tocs)
285 config["nav"] = new_nav
287 # Store the docs dir relative to project dir
288 # (note: same as in AllInformation class but we do not store the whole object)
289 project_root_dir = Path(os.path.abspath(config["config_file_path"])).parent
290 self.docs_dir_rel_proj = Path(config["docs_dir"]).relative_to(project_root_dir).as_posix()
292 def on_files(self, files, config):
293 """Remove the gallery examples *source* md files (in "examples_dirs") from the built website"""
295 # Get the list of gallery source files, possibly containing the readme.md that we wish to exclude
296 examples_dirs = self._get_dirs_relative_to(self.config["examples_dirs"], rel_to_dir=config["docs_dir"])
298 # Add the binder config files if needed
299 binder_cfg = self.config["binder"]
300 if binder_cfg: 300 ↛ 305line 300 didn't jump to line 305 because the condition on line 300 was always true
301 binder_files = [
302 Path(path).relative_to(config["docs_dir"]).as_posix() for path in binder_cfg["dependencies"]
303 ]
304 else:
305 binder_files = []
307 # Add the gallery config script if needed
308 if self.conf_script: 308 ↛ 318line 308 didn't jump to line 318 because the condition on line 308 was always true
309 conf_script = Path(self.conf_script).relative_to(config["docs_dir"])
310 conf_script_parent = conf_script.parent.as_posix()
311 if conf_script_parent == ".": 311 ↛ 314line 311 didn't jump to line 314 because the condition on line 311 was always true
312 conf_script_parent = ""
313 else:
314 conf_script_parent += r"\/"
315 conf_script_match = re.compile(rf"^{conf_script_parent}__\w*cache\w*__\/{conf_script.stem}[\w\-\.]*$")
316 conf_script = conf_script.as_posix()
318 def exclude(i):
319 # Get a posix version of the relative path so as to be sure to match ok
320 posix_src_path = Path(i.src_path).as_posix()
322 # Is it the conf script or a derived work of the conf script ?
323 if self.conf_script: 323 ↛ 328line 323 didn't jump to line 328 because the condition on line 323 was always true
324 if posix_src_path == conf_script or conf_script_match.match(posix_src_path):
325 return True
327 # Is it located in a gallery source directory ?
328 for d in examples_dirs:
329 if posix_src_path.startswith(d):
330 return True
332 # Is it a binder dependency file ?
333 if posix_src_path in binder_files:
334 return True
336 return False
338 out = []
339 for i in files:
340 if not exclude(i):
341 out.append(i)
343 return Files(out)
345 def _get_dirs_relative_to(self, dir_or_list_of_dirs: Union[str, List[str]], rel_to_dir: str) -> List[str]:
346 """Return dirs relative to another dir. If dirs is a single element, converts to a list first"""
348 # Make sure the list is a list (handle single elements)
349 if not isinstance(dir_or_list_of_dirs, list): 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true
350 dir_or_list_of_dirs = [dir_or_list_of_dirs]
352 # Get them relative to the mkdocs source dir
353 return [Path(relpath(Path(e), start=Path(rel_to_dir))).as_posix() for e in dir_or_list_of_dirs]
355 # def on_nav(self, nav, config, files):
356 # # Nav is already modded in on_pre_build, do not change it
357 # return nav
359 def on_page_content(self, html, page: Page, config: Config, files: Files):
360 """Edit the 'edit this page' link so that it points to gallery example source files."""
361 page_path = Path(page.file.src_path).as_posix()
362 try:
363 # Do we have a gallery example source file path for this page ?
364 src = self.md_to_src[page_path]
365 except KeyError:
366 pass
367 else:
368 # Note: page.edit_url is the concatenation of repo_url and edit_uri
369 # (see https://www.mkdocs.org/user-guide/configuration/)
371 if page.edit_url is not None: 371 ↛ 383line 371 didn't jump to line 383 because the condition on line 371 was always true
372 # Remove the dest gallery md file path relative to docs_dir
373 assert page.edit_url.endswith("/" + page_path)
374 edit_url = page.edit_url[: -len(page_path) - 1]
376 # Remove the docs_dir relative path with respect to project root
377 assert edit_url.endswith("/" + self.docs_dir_rel_proj)
378 edit_url = edit_url[: -len(self.docs_dir_rel_proj) - 1]
380 # Finally add the example source relative to project root
381 page.edit_url = f"{edit_url}/{src.as_posix()}"
383 return html
385 def on_serve(self, server, config, builder):
386 """Exclude gallery target dirs ("gallery_dirs") from monitored files to avoid neverending build loops."""
388 # self.observer.schedule(handler, path, recursive=recursive)
389 excluded_dirs = self.config["gallery_dirs"]
390 if isinstance(excluded_dirs, str):
391 excluded_dirs = [excluded_dirs] # a single dir
392 backrefs_dir = self.config["backreferences_dir"]
393 if backrefs_dir:
394 excluded_dirs.append(backrefs_dir)
396 def wrap_callback(original_callback):
397 def _callback(event):
398 for g in excluded_dirs:
399 if is_relative_to(g, Path(event.src_path)):
400 # ignore this event: the file is in the gallery target dir.
401 # log.info(f"Ignoring event: {event}")
402 return
403 return original_callback(event)
405 return _callback
407 # TODO this is an ugly hack...
408 # Find the objects in charge of monitoring the dirs and modify their callbacks
409 for _watch, handlers in server.observer._handlers.items():
410 for h in handlers:
411 h.on_any_event = wrap_callback(h.on_any_event)
413 return server
415 def on_post_build(self, config, **kwargs):
416 """Create one md file for each python example in the gallery."""
418 copy_binder_files(gallery_conf=self.config, mkdocs_conf=config)
419 summarize_failing_examples(gallery_conf=self.config, mkdocs_conf=config)
420 # TODO embed_code_links()
423def merge_extra_config(extra_config: Dict[str, Any], config):
424 """Extend the configuration 'markdown_extensions' list with extension_name if needed."""
426 for extension_cfg in extra_config["markdown_extensions"]:
427 if isinstance(extension_cfg, str):
428 extension_name = extension_cfg
429 if extension_name not in config["markdown_extensions"]: 429 ↛ 426line 429 didn't jump to line 426 because the condition on line 429 was always true
430 config["markdown_extensions"].append(extension_name)
431 elif isinstance(extension_cfg, dict): 431 ↛ 445line 431 didn't jump to line 445 because the condition on line 431 was always true
432 assert len(extension_cfg) == 1 # noqa
433 extension_name, extension_options = extension_cfg.popitem()
434 if extension_name not in config["markdown_extensions"]: 434 ↛ 436line 434 didn't jump to line 436 because the condition on line 434 was always true
435 config["markdown_extensions"].append(extension_name)
436 if extension_name not in config["mdx_configs"]: 436 ↛ 441line 436 didn't jump to line 441 because the condition on line 436 was always true
437 config["mdx_configs"][extension_name] = extension_options
438 else:
439 # Only add options that are not already set
440 # TODO should we warn ?
441 for cfg_key, cfg_val in extension_options.items():
442 if cfg_key not in config["mdx_configs"][extension_name]:
443 config["mdx_configs"][extension_name][cfg_key] = cfg_val
444 else:
445 raise TypeError(extension_cfg)
448# class SearchPlugin(BasePlugin):
449# """ Add a search feature to MkDocs. """
450#
451# config_scheme = (
452# ('lang', LangOption()),
453# ('separator', co.Type(str, default=r'[\s\-]+')),
454# ('min_search_length', co.Type(int, default=3)),
455# ('prebuild_index', co.Choice((False, True, 'node', 'python'), default=False)),
456# ('indexing', co.Choice(('full', 'sections', 'titles'), default='full'))
457# )
458#
459# def on_config(self, config, **kwargs):
460# "Add plugin templates and scripts to config."
461# if 'include_search_page' in config['theme'] and config['theme']['include_search_page']:
462# config['theme'].static_templates.add('search.html')
463# if not ('search_index_only' in config['theme'] and config['theme']['search_index_only']):
464# path = os.path.join(base_path, 'templates')
465# config['theme'].dirs.append(path)
466# if 'search/main.js' not in config['extra_javascript']:
467# config['extra_javascript'].append('search/main.js')
468# if self.config['lang'] is None:
469# # lang setting undefined. Set default based on theme locale
470# validate = self.config_scheme[0][1].run_validation
471# self.config['lang'] = validate(config['theme']['locale'].language)
472# # The `python` method of `prebuild_index` is pending deprecation as of version 1.2.
473# # TODO: Raise a deprecation warning in a future release (1.3?).
474# if self.config['prebuild_index'] == 'python':
475# log.info(
476# "The 'python' method of the search plugin's 'prebuild_index' config option "
477# "is pending deprecation and will not be supported in a future release."
478# )
479# return config
480#
481# def on_page_context(self, context, **kwargs):
482# "Add page to search index."
483# self.search_index.add_entry_from_context(context['page'])
484#
485# def on_post_build(self, config, **kwargs):
486# "Build search index."
487# output_base_path = os.path.join(config['site_dir'], 'search')
488# search_index = self.search_index.generate_search_index()
489# json_output_path = os.path.join(output_base_path, 'search_index.json')
490# utils.write_file(search_index.encode('utf-8'), json_output_path)
491#
492# if not ('search_index_only' in config['theme'] and config['theme']['search_index_only']):
493# # Include language support files in output. Copy them directly
494# # so that only the needed files are included.
495# files = []
496# if len(self.config['lang']) > 1 or 'en' not in self.config['lang']:
497# files.append('lunr.stemmer.support.js')
498# if len(self.config['lang']) > 1:
499# files.append('lunr.multi.js')
500# if ('ja' in self.config['lang'] or 'jp' in self.config['lang']):
501# files.append('tinyseg.js')
502# for lang in self.config['lang']:
503# if (lang != 'en'):
504# files.append(f'lunr.{lang}.js')
505#
506# for filename in files:
507# from_path = os.path.join(base_path, 'lunr-language', filename)
508# to_path = os.path.join(output_base_path, filename)
509# utils.copy_file(from_path, to_path)