Coverage for src/mkdocs_gallery/binder.py: 81%

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

7Binder utility functions 

8""" 

9import os 

10import shutil 

11from pathlib import Path 

12from typing import Dict 

13from urllib.parse import quote 

14 

15from tqdm import tqdm 

16 

17from . import glr_path_static, mkdocs_compatibility 

18from .errors import ConfigError 

19from .gen_data_model import GalleryScript 

20 

21logger = mkdocs_compatibility.getLogger("mkdocs-gallery") 

22 

23 

24def gen_binder_url(script: GalleryScript, binder_conf): 

25 """Generate a Binder URL according to the configuration in conf.py. 

26 

27 Parameters 

28 ---------- 

29 script: GalleryScript 

30 The script for which a Binder badge will be generated. 

31 binder_conf: dict or None 

32 The Binder configuration dictionary. See `gen_binder_md` for details. 

33 

34 Returns 

35 ------- 

36 binder_url : str 

37 A URL that can be used to direct the user to the live Binder environment. 

38 """ 

39 # Build the URL 

40 fpath_prefix = binder_conf.get("filepath_prefix") 

41 link_base = binder_conf.get("notebooks_dir") 

42 

43 # We want to keep the relative path to sub-folders 

44 path_link = os.path.join(link_base, script.ipynb_file_rel_site_root.as_posix()) 

45 

46 # In case our website is hosted in a sub-folder 

47 if fpath_prefix is not None: 47 ↛ 48line 47 didn't jump to line 48, because the condition on line 47 was never true

48 path_link = "/".join([fpath_prefix.strip("/"), path_link]) 

49 

50 # Make sure we have the right slashes (in case we're on Windows) 

51 path_link = path_link.replace(os.path.sep, "/") 

52 

53 # Create the URL 

54 # See https://mybinder.org/ to check that it is still the right one 

55 # Note: the branch will typically be gh-pages 

56 binder_url = "/".join( 

57 [ 

58 binder_conf["binderhub_url"], 

59 "v2", 

60 "gh", 

61 binder_conf["org"], 

62 binder_conf["repo"], 

63 quote(binder_conf["branch"]), 

64 ] 

65 ) 

66 

67 if binder_conf.get("use_jupyter_lab", False) is True: 67 ↛ 70line 67 didn't jump to line 70, because the condition on line 67 was never false

68 binder_url += "?urlpath=lab/tree/{}".format(quote(path_link)) 

69 else: 

70 binder_url += "?filepath={}".format(quote(path_link)) 

71 return binder_url 

72 

73 

74def gen_binder_md(script: GalleryScript, binder_conf: Dict): 

75 """Generate the MD + link for the Binder badge. 

76 

77 Parameters 

78 ---------- 

79 script: GalleryScript 

80 The script for which a Binder badge will be generated. 

81 

82 binder_conf: dict or None 

83 If a dictionary it must have the following keys: 

84 

85 'binderhub_url' 

86 The URL of the BinderHub instance that's running a Binder service. 

87 'org' 

88 The GitHub organization to which the documentation will be pushed. 

89 'repo' 

90 The GitHub repository to which the documentation will be pushed. 

91 'branch' 

92 The Git branch on which the documentation exists (e.g., gh-pages). 

93 'dependencies' 

94 A list of paths to dependency files that match the Binderspec. 

95 

96 Returns 

97 ------- 

98 md : str 

99 The Markdown for the Binder badge that links to this file. 

100 """ 

101 binder_url = gen_binder_url(script, binder_conf) 

102 

103 # TODO revisit this comment for mkdocs 

104 # In theory we should be able to use glr_path_static for this, but Sphinx only allows paths to be relative to the 

105 # build root. On Linux, absolute paths can be used and they work, but this does not seem to be 

106 # documented behavior: https://github.com/sphinx-doc/sphinx/issues/7772 

107 # And in any case, it does not work on Windows, so here we copy the SVG to `images` for each gallery and link to it 

108 # there. This will make a few copies, and there will be an extra in `_static` at the end of the build, but it at 

109 # least works... 

110 physical_path = script.gallery.images_dir / "binder_badge_logo.svg" 

111 if not physical_path.exists(): 

112 # Make sure parent dirs exists (this should not be necessary actually) 

113 physical_path.parent.mkdir(parents=True, exist_ok=True) 

114 shutil.copyfile(os.path.join(glr_path_static(), "binder_badge_logo.svg"), str(physical_path)) 

115 else: 

116 assert physical_path.is_file() # noqa 

117 

118 # Create the markdown image with a link 

119 return f"[![Launch binder](./images/binder_badge_logo.svg)]({binder_url}){{ .center}}" 

120 

121 

122def copy_binder_files(gallery_conf, mkdocs_conf): 

123 """Copy all Binder requirements and notebooks files.""" 

124 # if exception is not None: 

125 # return 

126 # 

127 # if app.builder.name not in ['html', 'readthedocs']: 

128 # return 

129 

130 # gallery_conf = app.config.sphinx_gallery_conf 

131 binder_conf = gallery_conf["binder"] 

132 

133 if not len(binder_conf) > 0: 133 ↛ 134line 133 didn't jump to line 134, because the condition on line 133 was never true

134 return 

135 

136 logger.info("copying binder requirements...") # , color='white') 

137 _copy_binder_reqs(binder_conf, mkdocs_conf) 

138 _copy_binder_notebooks(gallery_conf, mkdocs_conf) 

139 

140 

141def _copy_binder_reqs(binder_conf, mkdocs_conf): 

142 """Copy Binder requirements files to a ".binder" folder in the docs. 

143 

144 See https://mybinder.readthedocs.io/en/latest/using/config_files.html#config-files 

145 """ 

146 path_reqs = binder_conf.get("dependencies") 

147 

148 # Check that they exist (redundant since the check is already done by mkdocs.) 

149 for path in path_reqs: 

150 if not os.path.exists(path): 150 ↛ 151line 150 didn't jump to line 151, because the condition on line 150 was never true

151 raise ConfigError(f"Couldn't find the Binder requirements file: {path}, did you specify it correctly?") 

152 

153 # Destination folder: a ".binder" folder 

154 binder_folder = os.path.join(mkdocs_conf["site_dir"], ".binder") 

155 if not os.path.isdir(binder_folder): 

156 os.makedirs(binder_folder) 

157 

158 # Copy over the requirement files to the output directory 

159 for path in path_reqs: 

160 shutil.copy(path, binder_folder) 

161 

162 

163def _remove_ipynb_files(path, contents): 

164 """Given a list of files in `contents`, remove all files named `ipynb` or 

165 directories named `images` and return the result. 

166 

167 Used with the `shutil` "ignore" keyword to filter out non-ipynb files.""" 

168 contents_return = [] 

169 for entry in contents: 

170 if entry.endswith(".ipynb"): 

171 # Don't include ipynb files 

172 pass 

173 elif (entry != "images") and os.path.isdir(os.path.join(path, entry)): 

174 # Don't include folders not called "images" 

175 pass 

176 else: 

177 # Keep everything else 

178 contents_return.append(entry) 

179 return contents_return 

180 

181 

182def _copy_binder_notebooks(gallery_conf, mkdocs_conf): 

183 """Copy Jupyter notebooks to the binder notebooks directory. 

184 

185 Copy each output gallery directory structure but only including the 

186 Jupyter notebook files.""" 

187 

188 gallery_dirs = gallery_conf.get("gallery_dirs") 

189 binder_conf = gallery_conf.get("binder") 

190 notebooks_dir = os.path.join(mkdocs_conf["site_dir"], binder_conf.get("notebooks_dir")) 

191 shutil.rmtree(notebooks_dir, ignore_errors=True) 

192 os.makedirs(notebooks_dir) 

193 

194 if not isinstance(gallery_dirs, (list, tuple)): 194 ↛ 195line 194 didn't jump to line 195, because the condition on line 194 was never true

195 gallery_dirs = [gallery_dirs] 

196 

197 for gallery_dir in tqdm(gallery_dirs, desc=f"copying binder notebooks... "): 

198 gallery_dir_rel_docs_dir = Path(gallery_dir).relative_to(mkdocs_conf["docs_dir"]) 

199 shutil.copytree( 

200 gallery_dir, 

201 os.path.join(notebooks_dir, gallery_dir_rel_docs_dir), 

202 ignore=_remove_ipynb_files, 

203 ) 

204 

205 

206def check_binder_conf(binder_conf): 

207 """Check to make sure that the Binder configuration is correct.""" 

208 

209 # Grab the configuration and return None if it's not configured 

210 binder_conf = {} if binder_conf is None else binder_conf 

211 if not isinstance(binder_conf, dict): 211 ↛ 212line 211 didn't jump to line 212, because the condition on line 211 was never true

212 raise ConfigError("`binder_conf` must be a dictionary or None.") 

213 if len(binder_conf) == 0: 

214 return binder_conf 

215 

216 # Ensure all fields are populated 

217 req_values = ["binderhub_url", "org", "repo", "branch", "dependencies"] 

218 optional_values = ["filepath_prefix", "notebooks_dir", "use_jupyter_lab"] 

219 missing_values = [] 

220 for val in req_values: 

221 if binder_conf.get(val) is None: 221 ↛ 222line 221 didn't jump to line 222, because the condition on line 221 was never true

222 missing_values.append(val) 

223 

224 if len(missing_values) > 0: 224 ↛ 225line 224 didn't jump to line 225, because the condition on line 224 was never true

225 raise ConfigError(f"binder_conf is missing values for: {missing_values}") 

226 

227 for key in binder_conf.keys(): 

228 if key not in (req_values + optional_values): 228 ↛ 229line 228 didn't jump to line 229, because the condition on line 228 was never true

229 raise ConfigError(f"Unknown Binder config key: {key}") 

230 

231 # Ensure we have http in the URL 

232 if not any(binder_conf["binderhub_url"].startswith(ii) for ii in ["http://", "https://"]): 232 ↛ exit,   232 ↛ 2332 missed branches: 1) line 232 didn't finish the generator expression on line 232, 2) line 232 didn't jump to line 233, because the condition on line 232 was never true

233 raise ConfigError(f"did not supply a valid url, gave binderhub_url: {binder_conf['binderhub_url']}") 

234 

235 # Ensure we have at least one dependency file 

236 # Need at least one of these three files 

237 required_reqs_files = ["requirements.txt", "environment.yml", "Dockerfile"] 

238 

239 path_reqs = binder_conf["dependencies"] 

240 if isinstance(path_reqs, str): 240 ↛ 241line 240 didn't jump to line 241, because the condition on line 240 was never true

241 path_reqs = [path_reqs] 

242 binder_conf["dependencies"] = path_reqs 

243 elif not isinstance(path_reqs, (list, tuple)): 243 ↛ 244line 243 didn't jump to line 244, because the condition on line 243 was never true

244 raise ConfigError(f"`dependencies` value should be a list of strings. Got type {type(path_reqs)}.") 

245 

246 binder_conf["notebooks_dir"] = binder_conf.get("notebooks_dir", "notebooks") 

247 

248 path_reqs_filenames = [os.path.basename(ii) for ii in path_reqs] 

249 if not any(ii in path_reqs_filenames for ii in required_reqs_files): 249 ↛ exit,   249 ↛ 2502 missed branches: 1) line 249 didn't finish the generator expression on line 249, 2) line 249 didn't jump to line 250, because the condition on line 249 was never true

250 raise ConfigError( 

251 'Did not find one of `requirements.txt` or `environment.yml` in the "dependencies" section' 

252 " of the binder configuration for mkdocs-gallery. A path to at least one of these files must" 

253 " exist in your Binder dependencies." 

254 ) 

255 

256 return binder_conf