Coverage for src/mkdocs_gallery/binder.py: 81%
98 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"""
7Binder utility functions
8"""
9import os
10import shutil
11from pathlib import Path
12from typing import Dict
13from urllib.parse import quote
15from tqdm import tqdm
17from . import glr_path_static, mkdocs_compatibility
18from .errors import ConfigError
19from .gen_data_model import GalleryScript
21logger = mkdocs_compatibility.getLogger("mkdocs-gallery")
24def gen_binder_url(script: GalleryScript, binder_conf):
25 """Generate a Binder URL according to the configuration in conf.py.
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.
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")
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())
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])
50 # Make sure we have the right slashes (in case we're on Windows)
51 path_link = path_link.replace(os.path.sep, "/")
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 )
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 always 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
74def gen_binder_md(script: GalleryScript, binder_conf: Dict):
75 """Generate the MD + link for the Binder badge.
77 Parameters
78 ----------
79 script: GalleryScript
80 The script for which a Binder badge will be generated.
82 binder_conf: dict or None
83 If a dictionary it must have the following keys:
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.
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)
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
118 # Create the markdown image with a link
119 return f"[![Launch binder](./images/binder_badge_logo.svg)]({binder_url}){{ .center}}"
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
130 # gallery_conf = app.config.sphinx_gallery_conf
131 binder_conf = gallery_conf["binder"]
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
136 logger.info("copying binder requirements...") # , color='white')
137 _copy_binder_reqs(binder_conf, mkdocs_conf)
138 _copy_binder_notebooks(gallery_conf, mkdocs_conf)
141def _copy_binder_reqs(binder_conf, mkdocs_conf):
142 """Copy Binder requirements files to a ".binder" folder in the docs.
144 See https://mybinder.readthedocs.io/en/latest/using/config_files.html#config-files
145 """
146 path_reqs = binder_conf.get("dependencies")
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?")
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)
158 # Copy over the requirement files to the output directory
159 for path in path_reqs:
160 shutil.copy(path, binder_folder)
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.
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
182def _copy_binder_notebooks(gallery_conf, mkdocs_conf):
183 """Copy Jupyter notebooks to the binder notebooks directory.
185 Copy each output gallery directory structure but only including the
186 Jupyter notebook files."""
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)
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]
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 )
206def check_binder_conf(binder_conf):
207 """Check to make sure that the Binder configuration is correct."""
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
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)
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}")
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}")
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']}")
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"]
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)}.")
246 binder_conf["notebooks_dir"] = binder_conf.get("notebooks_dir", "notebooks")
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 )
256 return binder_conf