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

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 

15 

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 

25 

26from . import glr_path_static 

27from .binder import copy_binder_files 

28 

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 

32 

33IS_PY37 = parse_version("3.7") <= parse_version(platform.python_version()) < parse_version("3.8") 

34 

35mkdocs_version = version.parse(mkdocs_version_str) 

36is_mkdocs_14_or_greater = mkdocs_version >= version.parse("1.4") 

37 

38 

39class ConfigList(co.OptionallyRequired): 

40 """A list or single element of configuration matching a specific ConfigOption""" 

41 

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 

51 

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) 

59 

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 

68 

69 

70class Dir(co.Dir): 

71 """mkdocs.config.config_options.Dir replacement: returns a pathlib object instead of a string""" 

72 

73 def run_validation(self, value): 

74 return Path(co.Dir.run_validation(self, value)) 

75 

76 

77class File(co.File): 

78 """mkdocs.config.config_options.File replacement: returns a pathlib object instead of a string""" 

79 

80 def run_validation(self, value): 

81 return Path(co.File.run_validation(self, value)) 

82 

83 

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 

98 

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 

104 

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.""" 

110 

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) 

116 

117 def run_validation(self, value): 

118 """Fix SubConfig: errors and warnings were not caught 

119 

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}") 

127 

128 return self 

129 

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 ) 

143 

144 

145class GalleryPlugin(BasePlugin): 

146 # # Mandatory to display plotly graph within the site 

147 # import plotly.io as pio 

148 # pio.renderers.default = "sphinx_gallery" 

149 

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 ) 

199 

200 def on_config(self, config, **kwargs): 

201 """ 

202 TODO Add plugin templates and scripts to config. 

203 """ 

204 

205 from mkdocs.utils import yaml_load 

206 

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") 

213 

214 # Set Python version dependent emoji logic for backward compatibility 

215 mx_name = "materialx" if IS_PY37 else "material.extensions" 

216 

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 

221 

222 # to add notes such as http://squidfunk.github.io/mkdocs-material/extensions/admonition/ 

223 - admonition 

224 

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 

231 

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 

236 

237 

238""" 

239 extra_config = yaml_load(extra_config_yml) 

240 merge_extra_config(extra_config, config) 

241 

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') 

250 

251 # Handle our custom class - convert to a dict 

252 if self.config["binder"]: 

253 self.config["binder"] = dict(self.config["binder"]) 

254 

255 # Remember the conf script location for later (for excluding files in `on_files` below) 

256 self.conf_script = self.config["conf_script"] 

257 

258 # Use almost the original sphinx-gallery config validator 

259 self.config = parse_config(self.config, mkdocs_conf=config) 

260 

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') 

268 

269 return config 

270 

271 def on_pre_build(self, config, **kwargs): 

272 """Create one md file for each python example in the gallery, and update the navigation.""" 

273 

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) 

280 

281 galleries_tocs, self.md_to_src = generate_gallery_md(self.config, config) 

282 

283 # Update the nav for all galleries if needed 

284 new_nav = fill_mkdocs_nav(config, galleries_tocs) 

285 config["nav"] = new_nav 

286 

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() 

291 

292 def on_files(self, files, config): 

293 """Remove the gallery examples *source* md files (in "examples_dirs") from the built website""" 

294 

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"]) 

297 

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 = [] 

306 

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() 

317 

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() 

321 

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 

326 

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 

331 

332 # Is it a binder dependency file ? 

333 if posix_src_path in binder_files: 

334 return True 

335 

336 return False 

337 

338 out = [] 

339 for i in files: 

340 if not exclude(i): 

341 out.append(i) 

342 

343 return Files(out) 

344 

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""" 

347 

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] 

351 

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] 

354 

355 # def on_nav(self, nav, config, files): 

356 # # Nav is already modded in on_pre_build, do not change it 

357 # return nav 

358 

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/) 

370 

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] 

375 

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] 

379 

380 # Finally add the example source relative to project root 

381 page.edit_url = f"{edit_url}/{src.as_posix()}" 

382 

383 return html 

384 

385 def on_serve(self, server, config, builder): 

386 """Exclude gallery target dirs ("gallery_dirs") from monitored files to avoid neverending build loops.""" 

387 

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) 

395 

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) 

404 

405 return _callback 

406 

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) 

412 

413 return server 

414 

415 def on_post_build(self, config, **kwargs): 

416 """Create one md file for each python example in the gallery.""" 

417 

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() 

421 

422 

423def merge_extra_config(extra_config: Dict[str, Any], config): 

424 """Extend the configuration 'markdown_extensions' list with extension_name if needed.""" 

425 

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) 

446 

447 

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)