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 """
7 Binder utility functions
8 """
9 import os
10 import shutil
11 from pathlib import Path
12 from typing import Dict
13 from urllib.parse import quote
14
15 from tqdm import tqdm
16
17 from . import glr_path_static, mkdocs_compatibility
18 from .errors import ConfigError
19 from .gen_data_model import GalleryScript
20
21 logger = mkdocs_compatibility.getLogger("mkdocs-gallery")
22
23
24 def 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:
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:
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
74 def 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
122 def 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:
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
141 def _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):
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
163 def _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
182 def _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)):
195 gallery_dirs = [gallery_dirs]
196
-
F541
F-string is missing placeholders
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
206 def 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):
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:
222 missing_values.append(val)
223
224 if len(missing_values) > 0:
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):
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://"]):
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):
241 path_reqs = [path_reqs]
242 binder_conf["dependencies"] = path_reqs
243 elif not isinstance(path_reqs, (list, tuple)):
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):
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