Coverage for src/mkdocs_gallery/plugin.py: 71%

183 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-15 17:10 +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 

10from os.path import relpath 

11import re 

12from pathlib import Path 

13from typing import Any, Dict, List, Union 

14 

15from mkdocs import __version__ as mkdocs_version_str 

16from mkdocs.config import config_options as co 

17from mkdocs.config.base import Config, ValidationError 

18from mkdocs.exceptions import ConfigurationError 

19from mkdocs.plugins import BasePlugin 

20from mkdocs.structure.files import Files 

21from mkdocs.structure.pages import Page 

22from packaging import version 

23 

24from . import glr_path_static 

25from .binder import copy_binder_files 

26 

27# from .docs_resolv import embed_code_links 

28from .gen_gallery import fill_mkdocs_nav, generate_gallery_md, parse_config, summarize_failing_examples 

29from .utils import is_relative_to 

30 

31mkdocs_version = version.parse(mkdocs_version_str) 

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

33 

34 

35class ConfigList(co.OptionallyRequired): 

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

37 

38 def __init__( 

39 self, 

40 item_config: co.BaseConfigOption, 

41 single_elt_allowed: bool = True, 

42 **kwargs, 

43 ): 

44 super().__init__(**kwargs) 

45 self.single_elt_allowed = single_elt_allowed 

46 self.item_config = item_config 

47 

48 def run_validation(self, value): 

49 if not isinstance(value, (list, tuple)): 

50 if self.single_elt_allowed: 50 ↛ 53line 50 didn't jump to line 53, because the condition on line 50 was never false

51 value = (value,) 

52 else: 

53 msg = f"Expected a list but received a single element: {value}." 

54 raise ValidationError(msg) 

55 

56 # Validate all elements in the list 

57 result = [] 

58 for i, v in enumerate(value): 

59 try: 

60 result.append(self.item_config.validate(v)) 

61 except ValidationError as e: 

62 raise ValidationError(f"Error validating config item #{i+1}: {e}") 

63 return result 

64 

65 

66class Dir(co.Dir): 

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

68 

69 def run_validation(self, value): 

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

71 

72 

73class File(co.File): 

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

75 

76 def run_validation(self, value): 

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

78 

79 

80# Binder configuration is a "sub config". This has changed in mkdocs 1.4, handling both here. 

81if is_mkdocs_14_or_greater: 81 ↛ 103line 81 didn't jump to line 103, because the condition on line 81 was never false

82 # Construct a class where class attributes are the config options 

83 class _BinderOptions(co.Config): 

84 # Required keys 

85 org = co.Type(str) 

86 repo = co.Type(str) 

87 dependencies = ConfigList(File(exists=True)) 

88 # Optional keys 

89 branch = co.Type(str, default="gh-pages") 

90 binderhub_url = co.URL(default="https://mybinder.org") 

91 filepath_prefix = co.Optional(co.Type(str)) # default is None 

92 notebooks_dir = co.Optional(co.Type(str)) # default is "notebooks" 

93 use_jupyter_lab = co.Optional(co.Type(bool)) # default is False 

94 

95 def create_binder_config(): 

96 opt = co.SubConfig(_BinderOptions) 

97 # Set the default value to None so that it is not (invalid) empty dict anymore. GH#45 

98 opt.default = None 

99 return opt 

100 

101else: 

102 # Use MySubConfig 

103 class MySubConfig(co.SubConfig): 

104 """Same as SubConfig except that it will be an empty dict when nothing is provided by user, 

105 instead of a dict with all options containing their default values.""" 

106 

107 def validate(self, value): 

108 if value is None or len(value) == 0: 

109 return None 

110 else: 

111 return super(MySubConfig, self).validate(value) 

112 

113 def run_validation(self, value): 

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

115 

116 See https://github.com/mkdocs/mkdocs/pull/2710 

117 """ 

118 failed, self.warnings = Config.validate(self) 

119 if len(failed) > 0: 

120 # get the first failing one 

121 key, err = failed[0] 

122 raise ConfigurationError(f"Sub-option {key!r} configuration error: {err}") 

123 

124 return self 

125 

126 def create_binder_config(): 

127 return MySubConfig( 

128 # Required keys 

129 ("org", co.Type(str, required=True)), 

130 ("repo", co.Type(str, required=True)), 

131 ("dependencies", ConfigList(File(exists=True), required=True)), 

132 # Optional keys 

133 ("branch", co.Type(str, required=True, default="gh-pages")), 

134 ("binderhub_url", co.URL(required=True, default="https://mybinder.org")), 

135 ("filepath_prefix", co.Type(str)), # default is None 

136 ("notebooks_dir", co.Type(str)), # default is "notebooks" 

137 ("use_jupyter_lab", co.Type(bool)), # default is False 

138 ) 

139 

140 

141class GalleryPlugin(BasePlugin): 

142 # # Mandatory to display plotly graph within the site 

143 # import plotly.io as pio 

144 # pio.renderers.default = "sphinx_gallery" 

145 

146 config_scheme = ( 

147 ("conf_script", File(exists=True)), 

148 ("filename_pattern", co.Type(str)), 

149 ("ignore_pattern", co.Type(str)), 

150 ("examples_dirs", ConfigList(Dir(exists=True))), 

151 # 'reset_argv': DefaultResetArgv(), 

152 ("subsection_order", co.Choice(choices=(None, "ExplicitOrder"))), 

153 ( 

154 "within_subsection_order", 

155 co.Choice(choices=("FileNameSortKey", "NumberOfCodeLinesSortKey")), 

156 ), 

157 ("gallery_dirs", ConfigList(Dir(exists=False))), 

158 ("backreferences_dir", Dir(exists=False)), 

159 ("doc_module", ConfigList(co.Type(str))), 

160 # 'reference_url': {}, TODO how to link code to external functions? 

161 ("capture_repr", ConfigList(co.Type(str))), 

162 ("ignore_repr_types", co.Type(str)), 

163 # Build options 

164 ("plot_gallery", co.Type(bool)), 

165 ("download_all_examples", co.Type(bool)), 

166 ("abort_on_example_error", co.Type(bool)), 

167 ("only_warn_on_example_error", co.Type(bool)), 

168 # 'failing_examples': {}, # type: Set[str] 

169 # 'passing_examples': [], 

170 # 'stale_examples': [], 

171 ("run_stale_examples", co.Type(bool)), 

172 ("expected_failing_examples", ConfigList(File(exists=True))), 

173 ("thumbnail_size", ConfigList(co.Type(int), single_elt_allowed=False)), 

174 ("min_reported_time", co.Type(int)), 

175 ("binder", co.Optional(create_binder_config())), 

176 ("image_scrapers", ConfigList(co.Type(str))), 

177 ("compress_images", ConfigList(co.Type(str))), 

178 ("reset_modules", ConfigList(co.Type(str))), 

179 ("first_notebook_cell", co.Type(str)), 

180 ("last_notebook_cell", co.Type(str)), 

181 ("notebook_images", co.Type(bool)), 

182 # # 'pypandoc': False, 

183 ("remove_config_comments", co.Type(bool)), 

184 ("show_memory", co.Type(bool)), 

185 ("show_signature", co.Type(bool)), 

186 # 'junit': '', 

187 # 'log_level': {'backreference_missing': 'warning'}, 

188 ("inspect_global_variables", co.Type(bool)), 

189 # 'css': _KNOWN_CSS, 

190 ("matplotlib_animations", co.Type(bool)), 

191 ("image_srcset", ConfigList(co.Type(str))), 

192 ("default_thumb_file", File(exists=True)), 

193 ("line_numbers", co.Type(bool)), 

194 ) 

195 

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

197 """ 

198 TODO Add plugin templates and scripts to config. 

199 """ 

200 

201 from mkdocs.utils import yaml_load 

202 

203 # Enable navigation indexes in "material" theme, 

204 # see https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/#section-index-pages 

205 if config["theme"].name == "material": 

206 if "navigation.indexes" not in config["theme"]["features"]: 206 ↛ 210line 206 didn't jump to line 210, because the condition on line 206 was never false

207 if "toc.integrate" not in config["theme"]["features"]: 207 ↛ 210line 207 didn't jump to line 210, because the condition on line 207 was never false

208 config["theme"]["features"].append("navigation.indexes") 

209 

210 extra_config_yml = """ 

211markdown_extensions: 

212 # to declare attributes such as css classes on markdown elements. For example to change the color 

213 - attr_list 

214 

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

216 - admonition 

217 

218 # to display the code blocks https://squidfunk.github.io/mkdocs-material/reference/code-blocks/ 

219 - pymdownx.highlight 

220 - pymdownx.inlinehilite 

221 - pymdownx.details 

222 - pymdownx.superfences 

223 - pymdownx.snippets 

224 

225 # to have the download icons in the buttons 

226 - pymdownx.emoji: 

227 emoji_index: !!python/name:materialx.emoji.twemoji 

228 emoji_generator: !!python/name:materialx.emoji.to_svg 

229 

230 

231""" 

232 extra_config = yaml_load(extra_config_yml) 

233 merge_extra_config(extra_config, config) 

234 

235 # Append static resources 

236 static_resources_dir = glr_path_static() 

237 config["theme"].dirs.append(static_resources_dir) 

238 for css_file in os.listdir(static_resources_dir): 

239 if css_file.endswith(".css"): 

240 config["extra_css"].append(css_file) 

241 # config['theme'].static_templates.add('search.html') 

242 # config['extra_javascript'].append('search/main.js') 

243 

244 # Handle our custom class - convert to a dict 

245 if self.config["binder"]: 

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

247 

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

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

250 

251 # Use almost the original sphinx-gallery config validator 

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

253 

254 # TODO do we need to register those CSS files and how ? (they are already registered ads 

255 # for css in self.config['css']: 

256 # if css not in _KNOWN_CSS: 

257 # raise ConfigError('Unknown css %r, must be one of %r' 

258 # % (css, _KNOWN_CSS)) 

259 # if gallery_conf['app'] is not None: # can be None in testing 

260 # gallery_conf['app'].add_css_file(css + '.css') 

261 

262 return config 

263 

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

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

266 

267 # TODO ? 

268 # if 'sphinx.ext.autodoc' in app.extensions: 

269 # app.connect('autodoc-process-docstring', touch_empty_backreferences) 

270 # app.add_directive('minigallery', MiniGallery) 

271 # app.add_directive("image-sg", ImageSg) 

272 # imagesg_addnode(app) 

273 

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

275 

276 # Update the nav for all galleries if needed 

277 new_nav = fill_mkdocs_nav(config, galleries_tocs) 

278 config["nav"] = new_nav 

279 

280 # Store the docs dir relative to project dir 

281 # (note: same as in AllInformation class but we do not store the whole object) 

282 project_root_dir = Path(os.path.abspath(config["config_file_path"])).parent 

283 self.docs_dir_rel_proj = Path(config["docs_dir"]).relative_to(project_root_dir).as_posix() 

284 

285 def on_files(self, files, config): 

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

287 

288 # Get the list of gallery source files, possibly containing the readme.md that we wish to exclude 

289 examples_dirs = self._get_dirs_relative_to(self.config["examples_dirs"], rel_to_dir=config["docs_dir"]) 

290 

291 # Add the binder config files if needed 

292 binder_cfg = self.config["binder"] 

293 if binder_cfg: 293 ↛ 298line 293 didn't jump to line 298, because the condition on line 293 was never false

294 binder_files = [ 

295 Path(path).relative_to(config["docs_dir"]).as_posix() for path in binder_cfg["dependencies"] 

296 ] 

297 else: 

298 binder_files = [] 

299 

300 # Add the gallery config script if needed 

301 if self.conf_script: 301 ↛ 311line 301 didn't jump to line 311, because the condition on line 301 was never false

302 conf_script = Path(self.conf_script).relative_to(config["docs_dir"]) 

303 conf_script_parent = conf_script.parent.as_posix() 

304 if conf_script_parent == ".": 304 ↛ 307line 304 didn't jump to line 307, because the condition on line 304 was never false

305 conf_script_parent = "" 

306 else: 

307 conf_script_parent += r"\/" 

308 conf_script_match = re.compile(rf"^{conf_script_parent}__\w*cache\w*__\/{conf_script.stem}[\w\-\.]*$") 

309 conf_script = conf_script.as_posix() 

310 

311 def exclude(i): 

312 # Get a posix version of the relative path so as to be sure to match ok 

313 posix_src_path = Path(i.src_path).as_posix() 

314 

315 # Is it the conf script or a derived work of the conf script ? 

316 if self.conf_script: 316 ↛ 321line 316 didn't jump to line 321, because the condition on line 316 was never false

317 if posix_src_path == conf_script or conf_script_match.match(posix_src_path): 

318 return True 

319 

320 # Is it located in a gallery source directory ? 

321 for d in examples_dirs: 

322 if posix_src_path.startswith(d): 

323 return True 

324 

325 # Is it a binder dependency file ? 

326 if posix_src_path in binder_files: 

327 return True 

328 

329 return False 

330 

331 out = [] 

332 for i in files: 

333 if not exclude(i): 

334 out.append(i) 

335 

336 return Files(out) 

337 

338 def _get_dirs_relative_to(self, dir_or_list_of_dirs: Union[str, List[str]], rel_to_dir: str) -> List[str]: 

339 """Return dirs relative to another dir. If dirs is a single element, converts to a list first""" 

340 

341 # Make sure the list is a list (handle single elements) 

342 if not isinstance(dir_or_list_of_dirs, list): 342 ↛ 343line 342 didn't jump to line 343, because the condition on line 342 was never true

343 dir_or_list_of_dirs = [dir_or_list_of_dirs] 

344 

345 # Get them relative to the mkdocs source dir 

346 return [Path(relpath(Path(e), start=Path(rel_to_dir))).as_posix() for e in dir_or_list_of_dirs] 

347 

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

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

350 # return nav 

351 

352 def on_page_content(self, html, page: Page, config: Config, files: Files): 

353 """Edit the 'edit this page' link so that it points to gallery example source files.""" 

354 page_path = Path(page.file.src_path).as_posix() 

355 try: 

356 # Do we have a gallery example source file path for this page ? 

357 src = self.md_to_src[page_path] 

358 except KeyError: 

359 pass 

360 else: 

361 # Note: page.edit_url is the concatenation of repo_url and edit_uri 

362 # (see https://www.mkdocs.org/user-guide/configuration/) 

363 

364 if page.edit_url is not None: 364 ↛ 376line 364 didn't jump to line 376, because the condition on line 364 was never false

365 # Remove the dest gallery md file path relative to docs_dir 

366 assert page.edit_url.endswith("/" + page_path) 

367 edit_url = page.edit_url[: -len(page_path) - 1] 

368 

369 # Remove the docs_dir relative path with respect to project root 

370 assert edit_url.endswith("/" + self.docs_dir_rel_proj) 

371 edit_url = edit_url[: -len(self.docs_dir_rel_proj) - 1] 

372 

373 # Finally add the example source relative to project root 

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

375 

376 return html 

377 

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

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

380 

381 # self.observer.schedule(handler, path, recursive=recursive) 

382 excluded_dirs = self.config["gallery_dirs"] 

383 if isinstance(excluded_dirs, str): 

384 excluded_dirs = [excluded_dirs] # a single dir 

385 backrefs_dir = self.config["backreferences_dir"] 

386 if backrefs_dir: 

387 excluded_dirs.append(backrefs_dir) 

388 

389 def wrap_callback(original_callback): 

390 def _callback(event): 

391 for g in excluded_dirs: 

392 if is_relative_to(g, Path(event.src_path)): 

393 # ignore this event: the file is in the gallery target dir. 

394 # log.info(f"Ignoring event: {event}") 

395 return 

396 return original_callback(event) 

397 

398 return _callback 

399 

400 # TODO this is an ugly hack... 

401 # Find the objects in charge of monitoring the dirs and modify their callbacks 

402 for _watch, handlers in server.observer._handlers.items(): 

403 for h in handlers: 

404 h.on_any_event = wrap_callback(h.on_any_event) 

405 

406 return server 

407 

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

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

410 

411 copy_binder_files(gallery_conf=self.config, mkdocs_conf=config) 

412 summarize_failing_examples(gallery_conf=self.config, mkdocs_conf=config) 

413 # TODO embed_code_links() 

414 

415 

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

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

418 

419 for extension_cfg in extra_config["markdown_extensions"]: 

420 if isinstance(extension_cfg, str): 

421 extension_name = extension_cfg 

422 if extension_name not in config["markdown_extensions"]: 422 ↛ 419line 422 didn't jump to line 419, because the condition on line 422 was never false

423 config["markdown_extensions"].append(extension_name) 

424 elif isinstance(extension_cfg, dict): 424 ↛ 438line 424 didn't jump to line 438, because the condition on line 424 was never false

425 assert len(extension_cfg) == 1 # noqa 

426 extension_name, extension_options = extension_cfg.popitem() 

427 if extension_name not in config["markdown_extensions"]: 427 ↛ 429line 427 didn't jump to line 429, because the condition on line 427 was never false

428 config["markdown_extensions"].append(extension_name) 

429 if extension_name not in config["mdx_configs"]: 429 ↛ 434line 429 didn't jump to line 434, because the condition on line 429 was never false

430 config["mdx_configs"][extension_name] = extension_options 

431 else: 

432 # Only add options that are not already set 

433 # TODO should we warn ? 

434 for cfg_key, cfg_val in extension_options.items(): 

435 if cfg_key not in config["mdx_configs"][extension_name]: 

436 config["mdx_configs"][extension_name][cfg_key] = cfg_val 

437 else: 

438 raise TypeError(extension_cfg) 

439 

440 

441# class SearchPlugin(BasePlugin): 

442# """ Add a search feature to MkDocs. """ 

443# 

444# config_scheme = ( 

445# ('lang', LangOption()), 

446# ('separator', co.Type(str, default=r'[\s\-]+')), 

447# ('min_search_length', co.Type(int, default=3)), 

448# ('prebuild_index', co.Choice((False, True, 'node', 'python'), default=False)), 

449# ('indexing', co.Choice(('full', 'sections', 'titles'), default='full')) 

450# ) 

451# 

452# def on_config(self, config, **kwargs): 

453# "Add plugin templates and scripts to config." 

454# if 'include_search_page' in config['theme'] and config['theme']['include_search_page']: 

455# config['theme'].static_templates.add('search.html') 

456# if not ('search_index_only' in config['theme'] and config['theme']['search_index_only']): 

457# path = os.path.join(base_path, 'templates') 

458# config['theme'].dirs.append(path) 

459# if 'search/main.js' not in config['extra_javascript']: 

460# config['extra_javascript'].append('search/main.js') 

461# if self.config['lang'] is None: 

462# # lang setting undefined. Set default based on theme locale 

463# validate = self.config_scheme[0][1].run_validation 

464# self.config['lang'] = validate(config['theme']['locale'].language) 

465# # The `python` method of `prebuild_index` is pending deprecation as of version 1.2. 

466# # TODO: Raise a deprecation warning in a future release (1.3?). 

467# if self.config['prebuild_index'] == 'python': 

468# log.info( 

469# "The 'python' method of the search plugin's 'prebuild_index' config option " 

470# "is pending deprecation and will not be supported in a future release." 

471# ) 

472# return config 

473# 

474# def on_page_context(self, context, **kwargs): 

475# "Add page to search index." 

476# self.search_index.add_entry_from_context(context['page']) 

477# 

478# def on_post_build(self, config, **kwargs): 

479# "Build search index." 

480# output_base_path = os.path.join(config['site_dir'], 'search') 

481# search_index = self.search_index.generate_search_index() 

482# json_output_path = os.path.join(output_base_path, 'search_index.json') 

483# utils.write_file(search_index.encode('utf-8'), json_output_path) 

484# 

485# if not ('search_index_only' in config['theme'] and config['theme']['search_index_only']): 

486# # Include language support files in output. Copy them directly 

487# # so that only the needed files are included. 

488# files = [] 

489# if len(self.config['lang']) > 1 or 'en' not in self.config['lang']: 

490# files.append('lunr.stemmer.support.js') 

491# if len(self.config['lang']) > 1: 

492# files.append('lunr.multi.js') 

493# if ('ja' in self.config['lang'] or 'jp' in self.config['lang']): 

494# files.append('tinyseg.js') 

495# for lang in self.config['lang']: 

496# if (lang != 'en'): 

497# files.append(f'lunr.{lang}.js') 

498# 

499# for filename in files: 

500# from_path = os.path.join(base_path, 'lunr-language', filename) 

501# to_path = os.path.join(output_base_path, filename) 

502# utils.copy_file(from_path, to_path)