$", md_text, flags=re.DOTALL
189 | )
190 |
191 | lines = md_text.split("\n")
192 | in_math_block = False
193 | wrapped_lines = []
194 | equation_lines = []
195 |
196 | for line in lines:
197 | if line.strip() == ".. math::":
198 | in_math_block = True
199 | continue # Skip the '.. math::' line
200 |
201 | if in_math_block:
202 | if line.strip() == "":
203 | if equation_lines:
204 | # Join and wrap the equations, then reset
205 | wrapped_lines.append(f"$$ {' '.join(equation_lines)} $$")
206 | equation_lines = []
207 | elif line.startswith(" ") or line.startswith("\t"):
208 | equation_lines.append(line.strip())
209 | else:
210 | wrapped_lines.append(line)
211 |
212 | # If you leave the indented block, the math block ends
213 | if in_math_block and not (
214 | line.startswith(" ") or line.startswith("\t") or line.strip() == ""
215 | ):
216 | in_math_block = False
217 | if equation_lines:
218 | wrapped_lines.append(f"$$ {' '.join(equation_lines)} $$")
219 | equation_lines = []
220 | wrapped_lines.append(line)
221 |
222 | # Handle the case where the text ends with a math block
223 | if in_math_block and equation_lines:
224 | wrapped_lines.append(f"$$ {' '.join(equation_lines)} $$")
225 |
226 | return "\n".join(wrapped_lines)
227 |
228 |
229 | def _process_literal_blocks(md_text):
230 | md_lines = md_text.split("\n")
231 | new_lines = []
232 | in_literal_block = False
233 | literal_block_accumulator = []
234 |
235 | for line in md_lines:
236 | indent_level = len(line) - len(line.lstrip())
237 |
238 | if in_literal_block and (indent_level > 0 or line.strip() == ""):
239 | literal_block_accumulator.append(line.lstrip())
240 | elif in_literal_block:
241 | new_lines.extend(["```"] + literal_block_accumulator + ["```"])
242 | literal_block_accumulator = []
243 | if line.endswith("::"):
244 | # If the line endswith ::, a new literal block is starting.
245 | line = line[:-2] # Strip off the :: from the end
246 | if not line:
247 | # If the line contains only ::, we ignore it.
248 | continue
249 | else:
250 | # Only set in_literal_block to False if not starting new
251 | # literal block.
252 | in_literal_block = False
253 | # We've appended the entire literal block which just ended, but
254 | # still need to append the current line.
255 | new_lines.append(line)
256 | else:
257 | if line.endswith("::"):
258 | # A literal block is starting.
259 | in_literal_block = True
260 | line = line[:-2]
261 | if not line:
262 | # As above, if the line contains only ::, ignore it.
263 | continue
264 | new_lines.append(line)
265 |
266 | if literal_block_accumulator:
267 | # Handle case where a literal block ends the markdown cell.
268 | new_lines.extend(["```"] + literal_block_accumulator + ["```"])
269 |
270 | return "\n".join(new_lines)
271 |
272 |
273 | # try_examples identifies section headers after processing by numpydoc or
274 | # sphinx.ext.napoleon.
275 | # See https://numpydoc.readthedocs.io/en/stable/format.html for info on numpydoc
276 | # sections and
277 | # https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#docstring-sections
278 | # for info on sphinx.ext.napoleon sections.
279 |
280 | # The patterns below were identified by creating a docstring using all section
281 | # headers and processing it with both numpydoc and sphinx.ext.napoleon.
282 |
283 | # Examples section is a rubric for numpydoc and can be configured to be
284 | # either a rubric or admonition in sphinx.ext.napoleon.
285 | _examples_start_pattern = re.compile(r".. (rubric|admonition):: Examples")
286 | _next_section_pattern = re.compile(
287 | "|".join(
288 | # Newer versions of numpydoc enforce section order and only Attributes
289 | # and Methods sections can appear after an Examples section. All potential
290 | # numpydoc section headers are included here to support older or custom
291 | # numpydoc versions. e.g. at the time of this comment, SymPy is using a
292 | # custom version of numpydoc which allows for arbitrary section order.
293 | # sphinx.ext.napoleon allows for arbitrary section order and all potential
294 | # headers are included.
295 | [
296 | # Notes and References appear as rubrics in numpydoc and
297 | # can be configured to appear as either a rubric or
298 | # admonition in sphinx.ext.napoleon.
299 | r".\.\ rubric:: Notes",
300 | r".\.\ rubric:: References",
301 | r".\.\ admonition:: Notes",
302 | r".\.\ admonition:: References",
303 | # numpydoc only headers
304 | r":Attributes:",
305 | r".\.\ rubric:: Methods",
306 | r":Other Parameters:",
307 | r":Parameters:",
308 | r":Raises:",
309 | r":Returns:",
310 | # If examples section is last, processed by numpydoc may appear at end.
311 | r"\!\! processed by numpydoc \!\!",
312 | # sphinx.ext.napoleon only headers
313 | r"\.\. attribute::",
314 | r"\.\. method::",
315 | r":param .+:",
316 | r":raises .+:",
317 | r":returns:",
318 | # Headers generated by both extensions
319 | r".\.\ seealso::",
320 | r":Yields:",
321 | r":Warns:",
322 | # directives which can start a section with sphinx.ext.napoleon
323 | # with no equivalent when using numpydoc.
324 | r".\.\ attention::",
325 | r".\.\ caution::",
326 | r".\.\ danger::",
327 | r".\.\ error::",
328 | r".\.\ hint::",
329 | r".\.\ important::",
330 | r".\.\ tip::",
331 | r".\.\ todo::",
332 | r".\.\ warning::",
333 | ]
334 | )
335 | )
336 |
337 |
338 | def insert_try_examples_directive(lines, **options):
339 | """Adds try_examples directive to Examples section of a docstring.
340 |
341 | Hack to allow for a config option to enable try_examples functionality
342 | in all Examples sections (unless a comment ".. disable_try_examples" is
343 | added explicitly after the section header.)
344 |
345 |
346 | Parameters
347 | ----------
348 | docstring : list of str
349 | Lines of a docstring at time of "autodoc-process-docstring", which has
350 | been previously processed by numpydoc or sphinx.ext.napoleon.
351 |
352 | Returns
353 | -------
354 | list of str
355 | Updated version of the input docstring which has a try_examples directive
356 | inserted in the Examples section (if one exists) with all Examples content
357 | indented beneath it. Does nothing if the comment ".. disable_try_examples"
358 | is included at the top of the Examples section. Also a no-op if the
359 | try_examples directive is already included.
360 | """
361 | # Search for start of an Examples section
362 | for left_index, line in enumerate(lines):
363 | if _examples_start_pattern.search(line):
364 | break
365 | else:
366 | # No Examples section found
367 | return lines[:]
368 |
369 | # Jump to next line
370 | left_index += 1
371 | # Skip empty lines to get to the first content line
372 | while left_index < len(lines) and not lines[left_index].strip():
373 | left_index += 1
374 | if left_index == len(lines):
375 | # Examples section had no content, no need to insert directive.
376 | return lines[:]
377 |
378 | # Check for the ".. disable_try_examples" comment.
379 | if lines[left_index].strip() == ".. disable_try_examples":
380 | # If so, do not insert directive.
381 | return lines[:]
382 |
383 | # Check if the ".. try_examples::" directive already exists
384 | if ".. try_examples::" == lines[left_index].strip():
385 | # If so, don't need to insert again.
386 | return lines[:]
387 |
388 | # Find the end of the Examples section
389 | right_index = left_index
390 | while right_index < len(lines) and not _next_section_pattern.search(
391 | lines[right_index]
392 | ):
393 | right_index += 1
394 |
395 | # Check if we've reached the end of the docstring
396 | if right_index < len(lines) and "!! processed by numpydoc !!" in lines[right_index]:
397 | # Sometimes the .. appears on an earlier line than !! processed by numpydoc !!
398 | if not re.search(
399 | r"\.\.\s+\!\! processed by numpy doc \!\!", lines[right_index]
400 | ):
401 | while right_index > 0 and lines[right_index].strip() != "..":
402 | right_index -= 1
403 |
404 | # Add the ".. try_examples::" directive and indent the content of the Examples section
405 | new_lines = (
406 | lines[:left_index]
407 | + [".. try_examples::"]
408 | + [f" :{key}: {value}" for key, value in options.items()]
409 | + [""]
410 | + [" " + line for line in lines[left_index:right_index]]
411 | )
412 |
413 | # Append the remainder of the docstring, if there is any
414 | if right_index < len(lines):
415 | new_lines += [""] + lines[right_index:]
416 |
417 | return new_lines
418 |
--------------------------------------------------------------------------------
/jupyterlite_sphinx/jupyterlite_sphinx.css:
--------------------------------------------------------------------------------
1 | .jupyterlite_sphinx_raw_iframe {
2 | border-width: 1px;
3 | border-style: solid;
4 | border-color: #d8d8d8;
5 | box-shadow: 0 0.2rem 0.5rem #d8d8d8;
6 | }
7 |
8 | .jupyterlite_sphinx_iframe_container {
9 | border-width: 1px;
10 | border-style: solid;
11 | border-color: #d8d8d8;
12 | position: relative;
13 | cursor: pointer;
14 | box-shadow: 0 0.2rem 0.5rem rgba(19, 23, 29, 0.4);
15 | margin-bottom: 1.5rem;
16 | }
17 |
18 | .jupyterlite_sphinx_iframe {
19 | z-index: 1;
20 | position: relative;
21 | border-style: none;
22 | }
23 |
24 | .jupyterlite_sphinx_try_it_button {
25 | z-index: 0;
26 | position: absolute;
27 | left: 50%;
28 | top: 50%;
29 | transform: translateY(-50%) translateX(-50%);
30 | height: 100px;
31 | width: 100px;
32 | line-height: 100px;
33 | text-align: center;
34 | white-space: nowrap;
35 | background-color: #f7dc1e;
36 | color: #13171d;
37 | border-radius: 50%;
38 | font-family: vibur;
39 | font-size: larger;
40 | box-shadow: 0 0.2rem 0.5rem rgba(19, 23, 29, 0.4);
41 | }
42 |
43 | .try_examples_outer_container {
44 | position: relative;
45 | }
46 |
47 | .hidden {
48 | display: none;
49 | }
50 |
51 | .jupyterlite_sphinx_spinner {
52 | /* From https://css-loaders.com/spinner/ */
53 | position: absolute;
54 | z-index: 0;
55 | top: 50%;
56 | left: 50%;
57 | width: 50px;
58 | aspect-ratio: 1;
59 | border-radius: 50%;
60 | background:
61 | radial-gradient(farthest-side, #ffa516 94%, #0000) top/8px 8px no-repeat,
62 | conic-gradient(#0000 30%, #ffa516);
63 | -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 8px), #000 0);
64 | animation: l13 1s infinite linear;
65 | }
66 | @keyframes l13 {
67 | 100% {
68 | transform: rotate(1turn);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/jupyterlite_sphinx/jupyterlite_sphinx.js:
--------------------------------------------------------------------------------
1 | window.jupyterliteShowIframe = (tryItButtonId, iframeSrc) => {
2 | const tryItButton = document.getElementById(tryItButtonId);
3 | const iframe = document.createElement("iframe");
4 | const buttonRect = tryItButton.getBoundingClientRect();
5 |
6 | const spinner = document.createElement("div");
7 | // hardcoded spinner height and width needs to match what is in css.
8 | const spinnerHeight = 50; // px
9 | const spinnerWidth = 50; // px
10 | spinner.classList.add("jupyterlite_sphinx_spinner");
11 | spinner.style.display = "none";
12 | // Add negative margins to center the spinner
13 | spinner.style.marginTop = `-${spinnerHeight / 2}px`;
14 | spinner.style.marginLeft = `-${spinnerWidth / 2}px`;
15 |
16 | iframe.src = iframeSrc;
17 | iframe.width = iframe.height = "100%";
18 | iframe.classList.add("jupyterlite_sphinx_iframe");
19 |
20 | tryItButton.style.display = "none";
21 | spinner.style.display = "block";
22 |
23 | tryItButton.parentNode.appendChild(spinner);
24 | tryItButton.parentNode.appendChild(iframe);
25 | };
26 |
27 | window.jupyterliteConcatSearchParams = (iframeSrc, params) => {
28 | const baseURL = window.location.origin;
29 | const iframeUrl = new URL(iframeSrc, baseURL);
30 |
31 | let pageParams = new URLSearchParams(window.location.search);
32 |
33 | if (params === true) {
34 | params = Array.from(pageParams.keys());
35 | } else if (params === false) {
36 | params = [];
37 | } else if (!Array.isArray(params)) {
38 | console.error("The search parameters are not an array");
39 | }
40 |
41 | params.forEach((param) => {
42 | value = pageParams.get(param);
43 | if (value !== null) {
44 | iframeUrl.searchParams.append(param, value);
45 | }
46 | });
47 |
48 | if (iframeUrl.searchParams.size) {
49 | return `${iframeSrc.split("?")[0]}?${iframeUrl.searchParams.toString()}`;
50 | } else {
51 | return iframeSrc;
52 | }
53 | };
54 |
55 | window.tryExamplesShowIframe = (
56 | examplesContainerId,
57 | iframeContainerId,
58 | iframeParentContainerId,
59 | iframeSrc,
60 | iframeHeight,
61 | ) => {
62 | const examplesContainer = document.getElementById(examplesContainerId);
63 | const iframeParentContainer = document.getElementById(
64 | iframeParentContainerId,
65 | );
66 | const iframeContainer = document.getElementById(iframeContainerId);
67 | var height;
68 |
69 | let iframe = iframeContainer.querySelector(
70 | "iframe.jupyterlite_sphinx_iframe",
71 | );
72 |
73 | if (!iframe) {
74 | // Add spinner
75 | const spinner = document.createElement("div");
76 | // hardcoded spinner width needs to match what is in css.
77 | const spinnerHeight = 50; // px
78 | const spinnerWidth = 50; // px
79 | spinner.classList.add("jupyterlite_sphinx_spinner");
80 | iframeContainer.appendChild(spinner);
81 |
82 | const examples = examplesContainer.querySelector(".try_examples_content");
83 | iframe = document.createElement("iframe");
84 | iframe.src = iframeSrc;
85 | iframe.style.width = "100%";
86 | if (iframeHeight !== "None") {
87 | height = parseInt(iframeHeight);
88 | } else {
89 | height = Math.max(tryExamplesGlobalMinHeight, examples.offsetHeight);
90 | }
91 |
92 | /* Get spinner position. It will be centered in the iframe, unless the
93 | * iframe extends beyond the viewport, in which case it will be centered
94 | * between the top of the iframe and the bottom of the viewport.
95 | */
96 | const examplesTop = examples.getBoundingClientRect().top;
97 | const viewportBottom = window.innerHeight;
98 | const spinnerTop = 0.5 * Math.min(viewportBottom - examplesTop, height);
99 | spinner.style.top = `${spinnerTop}px`;
100 | // Add negative margins to center the spinner
101 | spinner.style.marginTop = `-${spinnerHeight / 2}px`;
102 | spinner.style.marginLeft = `-${spinnerWidth / 2}px`;
103 |
104 | iframe.style.height = `${height}px`;
105 | iframe.classList.add("jupyterlite_sphinx_iframe");
106 | examplesContainer.classList.add("hidden");
107 |
108 | iframeContainer.appendChild(iframe);
109 | } else {
110 | examplesContainer.classList.add("hidden");
111 | }
112 | iframeParentContainer.classList.remove("hidden");
113 | };
114 |
115 | window.tryExamplesHideIframe = (
116 | examplesContainerId,
117 | iframeParentContainerId,
118 | ) => {
119 | const examplesContainer = document.getElementById(examplesContainerId);
120 | const iframeParentContainer = document.getElementById(
121 | iframeParentContainerId,
122 | );
123 |
124 | iframeParentContainer.classList.add("hidden");
125 | examplesContainer.classList.remove("hidden");
126 | };
127 |
128 | // this will be used by the "Open in tab" button that is present next
129 | // # to the "go back" button after an iframe is made visible.
130 | window.openInNewTab = (examplesContainerId, iframeParentContainerId) => {
131 | const examplesContainer = document.getElementById(examplesContainerId);
132 | const iframeParentContainer = document.getElementById(
133 | iframeParentContainerId,
134 | );
135 |
136 | window.open(
137 | // we make some assumption that there is a single iframe and the the src is what we want to open.
138 | // Maybe we should have tabs open JupyterLab by default.
139 | iframeParentContainer.getElementsByTagName("iframe")[0].getAttribute("src"),
140 | );
141 | tryExamplesHideIframe(examplesContainerId, iframeParentContainerId);
142 | };
143 |
144 | /* Global variable for try_examples iframe minHeight. Defaults to 0 but can be
145 | * modified based on configuration in try_examples.json */
146 | var tryExamplesGlobalMinHeight = 0;
147 | /* Global variable to check if config has been loaded. This keeps it from getting
148 | * loaded multiple times if there are multiple try_examples directives on one page
149 | */
150 | var tryExamplesConfigLoaded = false;
151 |
152 | // This function is used to check if the current device is a mobile device.
153 | // We assume the authenticity of the user agent string is enough to
154 | // determine that, and we also check the window size as a fallback.
155 | window.isMobileDevice = (() => {
156 | let cachedUAResult = null;
157 | let hasLogged = false;
158 |
159 | const checkUserAgent = () => {
160 | if (cachedUAResult !== null) {
161 | return cachedUAResult;
162 | }
163 |
164 | const mobilePatterns = [
165 | /Android/i,
166 | /webOS/i,
167 | /iPhone/i,
168 | /iPad/i,
169 | /iPod/i,
170 | /BlackBerry/i,
171 | /IEMobile/i,
172 | /Windows Phone/i,
173 | /Opera Mini/i,
174 | /SamsungBrowser/i,
175 | /UC.*Browser|UCWEB/i,
176 | /MiuiBrowser/i,
177 | /Mobile/i,
178 | /Tablet/i,
179 | ];
180 |
181 | cachedUAResult = mobilePatterns.some((pattern) =>
182 | pattern.test(navigator.userAgent),
183 | );
184 | return cachedUAResult;
185 | };
186 |
187 | return () => {
188 | const isMobileBySize =
189 | window.innerWidth <= 480 || window.innerHeight <= 480;
190 | const isLikelyMobile = checkUserAgent() || isMobileBySize;
191 |
192 | if (isLikelyMobile && !hasLogged) {
193 | console.log(
194 | "Either a mobile device detected or the screen was resized. Disabling interactive example buttons to conserve bandwidth.",
195 | );
196 | hasLogged = true;
197 | }
198 |
199 | return isLikelyMobile;
200 | };
201 | })();
202 |
203 | // A config loader with request deduplication + permanent caching
204 | const ConfigLoader = (() => {
205 | let configLoadPromise = null;
206 |
207 | const loadConfig = async (configFilePath) => {
208 | if (window.isMobileDevice()) {
209 | const buttons = document.getElementsByClassName("try_examples_button");
210 | for (let i = 0; i < buttons.length; i++) {
211 | buttons[i].classList.add("hidden");
212 | }
213 | tryExamplesConfigLoaded = true; // mock it
214 | return;
215 | }
216 |
217 | if (tryExamplesConfigLoaded) {
218 | return;
219 | }
220 |
221 | // Return the existing promise if the request is in progress, as we
222 | // don't want to make multiple requests for the same file. This
223 | // can happen if there are several try_examples directives on the
224 | // same page.
225 | if (configLoadPromise) {
226 | return configLoadPromise;
227 | }
228 |
229 | // Create and cache the promise for the config request
230 | configLoadPromise = (async () => {
231 | try {
232 | // Add a timestamp as query parameter to ensure a cached version of the
233 | // file is not used.
234 | const timestamp = new Date().getTime();
235 | const configFileUrl = `${configFilePath}?cb=${timestamp}`;
236 | const currentPageUrl = window.location.pathname;
237 |
238 | const response = await fetch(configFileUrl);
239 | if (!response.ok) {
240 | if (response.status === 404) {
241 | console.log("Optional try_examples config file not found.");
242 | return;
243 | }
244 | throw new Error(`Error fetching ${configFilePath}`);
245 | }
246 |
247 | const data = await response.json();
248 | if (!data) {
249 | return;
250 | }
251 |
252 | // Set minimum iframe height based on value in config file
253 | if (data.global_min_height) {
254 | tryExamplesGlobalMinHeight = parseInt(data.global_min_height);
255 | }
256 |
257 | // Disable interactive examples if file matches one of the ignore patterns
258 | // by hiding try_examples_buttons.
259 | Patterns = data.ignore_patterns;
260 | for (let pattern of Patterns) {
261 | let regex = new RegExp(pattern);
262 | if (regex.test(currentPageUrl)) {
263 | var buttons = document.getElementsByClassName(
264 | "try_examples_button",
265 | );
266 | for (var i = 0; i < buttons.length; i++) {
267 | buttons[i].classList.add("hidden");
268 | }
269 | break;
270 | }
271 | }
272 | } catch (error) {
273 | console.error(error);
274 | } finally {
275 | tryExamplesConfigLoaded = true;
276 | }
277 | })();
278 |
279 | return configLoadPromise;
280 | };
281 |
282 | return {
283 | loadConfig,
284 | // for testing/debugging only, could be removed
285 | resetState: () => {
286 | tryExamplesConfigLoaded = false;
287 | configLoadPromise = null;
288 | },
289 | };
290 | })();
291 |
292 | // Add a resize handler that will update the buttons' visibility on
293 | // orientation changes
294 | let resizeTimeout;
295 | window.addEventListener("resize", () => {
296 | clearTimeout(resizeTimeout);
297 | resizeTimeout = setTimeout(() => {
298 | if (!tryExamplesConfigLoaded) return; // since we won't interfere if the config isn't loaded
299 |
300 | const buttons = document.getElementsByClassName("try_examples_button");
301 | const shouldHide = window.isMobileDevice();
302 |
303 | for (let i = 0; i < buttons.length; i++) {
304 | if (shouldHide) {
305 | buttons[i].classList.add("hidden");
306 | } else {
307 | buttons[i].classList.remove("hidden");
308 | }
309 | }
310 | }, 250);
311 | });
312 |
313 | window.loadTryExamplesConfig = ConfigLoader.loadConfig;
314 |
315 | window.toggleTryExamplesButtons = () => {
316 | /* Toggle visibility of TryExamples buttons. For use in console for debug
317 | * purposes. */
318 | var buttons = document.getElementsByClassName("try_examples_button");
319 |
320 | for (var i = 0; i < buttons.length; i++) {
321 | buttons[i].classList.toggle("hidden");
322 | }
323 | };
324 |
--------------------------------------------------------------------------------
/jupyterlite_sphinx/jupyterlite_sphinx.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import json
4 | from uuid import uuid4
5 | import shutil
6 | import re
7 | from typing import Dict, Any, List
8 |
9 | from pathlib import Path
10 |
11 | from urllib.parse import quote
12 |
13 | import subprocess
14 | from subprocess import CompletedProcess
15 |
16 | from docutils.parsers.rst import directives
17 | from docutils.nodes import SkipNode, Element
18 | from docutils import nodes
19 |
20 | from sphinx.application import Sphinx
21 | from sphinx.util.docutils import SphinxDirective
22 | from sphinx.util.fileutil import copy_asset
23 | from sphinx.parsers import RSTParser
24 |
25 | from ._try_examples import examples_to_notebook, insert_try_examples_directive
26 |
27 | import jupytext
28 | import nbformat
29 |
30 | try:
31 | import voici
32 | except ImportError:
33 | voici = None
34 |
35 | HERE = Path(__file__).parent
36 |
37 | CONTENT_DIR = "_contents"
38 | JUPYTERLITE_DIR = "lite"
39 |
40 |
41 | # Used for nodes that do not need to be rendered
42 | def skip(self, node):
43 | raise SkipNode
44 |
45 |
46 | # Used to render an element node as HTML
47 | def visit_element_html(self, node):
48 | self.body.append(node.html())
49 | raise SkipNode
50 |
51 |
52 | class _PromptedIframe(Element):
53 | def __init__(
54 | self,
55 | rawsource="",
56 | *children,
57 | iframe_src="",
58 | width="100%",
59 | height="100%",
60 | prompt=False,
61 | prompt_color=None,
62 | search_params="false",
63 | **attributes,
64 | ):
65 | super().__init__(
66 | "",
67 | iframe_src=iframe_src,
68 | width=width,
69 | height=height,
70 | prompt=prompt,
71 | prompt_color=prompt_color,
72 | search_params=search_params,
73 | )
74 |
75 | def html(self):
76 | iframe_src = self["iframe_src"]
77 | search_params = self["search_params"]
78 |
79 | if self["prompt"]:
80 | prompt = (
81 | self["prompt"] if isinstance(self["prompt"], str) else "Try It Live!"
82 | )
83 | prompt_color = (
84 | self["prompt_color"] if self["prompt_color"] is not None else "#f7dc1e"
85 | )
86 |
87 | placeholder_id = uuid4()
88 | container_style = f'width: {self["width"]}; height: {self["height"]};'
89 |
90 | return f"""
91 |
99 |
104 | {prompt}
105 |
106 |
107 | """
108 |
109 | return (
110 | f''
112 | )
113 |
114 |
115 | class _InTab(Element):
116 | def __init__(
117 | self,
118 | rawsource="",
119 | *children,
120 | prefix=JUPYTERLITE_DIR,
121 | notebook=None,
122 | lite_options={},
123 | button_text=None,
124 | **attributes,
125 | ):
126 | app_path = self.lite_app
127 | if notebook is not None:
128 | lite_options["path"] = notebook
129 | app_path = f"{self.lite_app}{self.notebooks_path}"
130 |
131 | options = "&".join(
132 | [f"{key}={quote(value)}" for key, value in lite_options.items()]
133 | )
134 | self.lab_src = (
135 | f'{prefix}/{app_path}{f"index.html?{options}" if options else ""}'
136 | )
137 |
138 | self.button_text = button_text
139 |
140 | super().__init__(
141 | rawsource,
142 | **attributes,
143 | )
144 |
145 | def html(self):
146 | return (
147 | '"
150 | )
151 |
152 |
153 | class _LiteIframe(_PromptedIframe):
154 | def __init__(
155 | self,
156 | rawsource="",
157 | *children,
158 | prefix=JUPYTERLITE_DIR,
159 | content=[],
160 | notebook=None,
161 | lite_options={},
162 | **attributes,
163 | ):
164 | if content:
165 | code_lines = ["" if not line.strip() else line for line in content]
166 | code = "\n".join(code_lines)
167 |
168 | lite_options["code"] = code
169 |
170 | app_path = self.lite_app
171 | if notebook is not None:
172 | lite_options["path"] = notebook
173 | app_path = f"{self.lite_app}{self.notebooks_path}"
174 |
175 | options = "&".join(
176 | [f"{key}={quote(value)}" for key, value in lite_options.items()]
177 | )
178 |
179 | iframe_src = f'{prefix}/{app_path}{f"index.html?{options}" if options else ""}'
180 |
181 | if "iframe_src" in attributes:
182 | if attributes["iframe_src"] != iframe_src:
183 | raise ValueError(
184 | f'Two different values of iframe_src {attributes["iframe_src"]=},{iframe_src=}, try upgrading sphinx to v 7.2.0 or more recent'
185 | )
186 | del attributes["iframe_src"]
187 |
188 | super().__init__(rawsource, *children, iframe_src=iframe_src, **attributes)
189 |
190 |
191 | class RepliteIframe(_LiteIframe):
192 | """Appended to the doctree by the RepliteDirective directive
193 |
194 | Renders an iframe that shows a repl with JupyterLite.
195 | """
196 |
197 | lite_app = "repl/"
198 | notebooks_path = ""
199 |
200 |
201 | class JupyterLiteIframe(_LiteIframe):
202 | """Appended to the doctree by the JupyterliteDirective directive
203 |
204 | Renders an iframe that shows a Notebook with JupyterLite.
205 | """
206 |
207 | lite_app = "lab/"
208 | notebooks_path = ""
209 |
210 |
211 | class BaseNotebookTab(_InTab):
212 | """Base class for notebook tab implementations. We subclass this
213 | to create more specific configurations around how tabs are rendered."""
214 |
215 | lite_app = None
216 | notebooks_path = None
217 | default_button_text = "Open as a notebook"
218 |
219 |
220 | class JupyterLiteTab(BaseNotebookTab):
221 | """Appended to the doctree by the JupyterliteDirective directive
222 |
223 | Renders a button that opens a Notebook with JupyterLite in a new tab.
224 | """
225 |
226 | lite_app = "lab/"
227 | notebooks_path = ""
228 |
229 |
230 | class NotebookLiteTab(BaseNotebookTab):
231 | """Appended to the doctree by the NotebookliteDirective directive
232 |
233 | Renders a button that opens a Notebook with NotebookLite in a new tab.
234 | """
235 |
236 | lite_app = "tree/"
237 | notebooks_path = "../notebooks/"
238 |
239 |
240 | # We do not inherit from _InTab here because Replite
241 | # has a different URL structure and we need to ensure
242 | # that the code is serialised to be passed to the URL.
243 | class RepliteTab(Element):
244 | """Appended to the doctree by the RepliteDirective directive
245 |
246 | Renders a button that opens a REPL with JupyterLite in a new tab.
247 | """
248 |
249 | lite_app = "repl/"
250 | notebooks_path = ""
251 |
252 | def __init__(
253 | self,
254 | rawsource="",
255 | *children,
256 | prefix=JUPYTERLITE_DIR,
257 | content=[],
258 | notebook=None,
259 | lite_options={},
260 | button_text=None,
261 | **attributes,
262 | ):
263 | # For a new-tabbed variant, we need to ensure we process the content
264 | # into properly encoded code for passing it to the URL.
265 | if content:
266 | code_lines: list[str] = [
267 | "" if not line.strip() else line for line in content
268 | ]
269 | code = "\n".join(code_lines)
270 | lite_options["code"] = code
271 |
272 | if "execute" in lite_options and lite_options["execute"] == "0":
273 | lite_options["execute"] = "0"
274 |
275 | app_path = self.lite_app
276 | if notebook is not None:
277 | lite_options["path"] = notebook
278 | app_path = f"{self.lite_app}{self.notebooks_path}"
279 |
280 | options = "&".join(
281 | [f"{key}={quote(value)}" for key, value in lite_options.items()]
282 | )
283 |
284 | self.lab_src = (
285 | f'{prefix}/{app_path}{f"index.html?{options}" if options else ""}'
286 | )
287 |
288 | self.button_text = button_text
289 |
290 | super().__init__(
291 | rawsource,
292 | **attributes,
293 | )
294 |
295 | def html(self):
296 | return (
297 | '"
300 | )
301 |
302 |
303 | class NotebookLiteIframe(_LiteIframe):
304 | """Appended to the doctree by the NotebookliteDirective directive
305 |
306 | Renders an iframe that shows a Notebook with NotebookLite.
307 | """
308 |
309 | lite_app = "tree/"
310 | notebooks_path = "../notebooks/"
311 |
312 |
313 | class VoiciBase:
314 | """Base class with common Voici application paths and URL structure"""
315 |
316 | lite_app = "voici/"
317 |
318 | @classmethod
319 | def get_full_path(cls, notebook=None):
320 | """Get the complete Voici path based on whether a notebook is provided."""
321 | if notebook is not None:
322 | # For notebooks, use render path with html extension
323 | return f"{cls.lite_app}render/{notebook.replace('.ipynb', '.html')}"
324 | # Default to tree view
325 | return f"{cls.lite_app}tree"
326 |
327 |
328 | class VoiciIframe(_PromptedIframe):
329 | """Appended to the doctree by the VoiciDirective directive
330 |
331 | Renders an iframe that shows a Notebook with Voici.
332 | """
333 |
334 | def __init__(
335 | self,
336 | rawsource="",
337 | *children,
338 | prefix=JUPYTERLITE_DIR,
339 | notebook=None,
340 | lite_options={},
341 | **attributes,
342 | ):
343 | app_path = VoiciBase.get_full_path(notebook)
344 | options = "&".join(
345 | [f"{key}={quote(value)}" for key, value in lite_options.items()]
346 | )
347 |
348 | # If a notebook is provided, open it in the render view. Else, we default to the tree view.
349 | iframe_src = f'{prefix}/{app_path}{f"index.html?{options}" if options else ""}'
350 |
351 | super().__init__(rawsource, *children, iframe_src=iframe_src, **attributes)
352 |
353 |
354 | # We do not inherit from BaseNotebookTab here because
355 | # Voici has a different URL structure.
356 | class VoiciTab(Element):
357 | """Tabbed implementation for the Voici interface"""
358 |
359 | def __init__(
360 | self,
361 | rawsource="",
362 | *children,
363 | prefix=JUPYTERLITE_DIR,
364 | notebook=None,
365 | lite_options={},
366 | button_text=None,
367 | **attributes,
368 | ):
369 |
370 | self.lab_src = f"{prefix}/"
371 |
372 | app_path = VoiciBase.get_full_path(notebook)
373 | options = "&".join(
374 | [f"{key}={quote(value)}" for key, value in lite_options.items()]
375 | )
376 |
377 | # If a notebook is provided, open it in a new tab. Else, we default to the tree view.
378 | self.lab_src = f'{prefix}/{app_path}{f"?{options}" if options else ""}'
379 |
380 | self.button_text = button_text
381 |
382 | super().__init__(
383 | rawsource,
384 | **attributes,
385 | )
386 |
387 | def html(self):
388 | return (
389 | '"
392 | )
393 |
394 |
395 | class RepliteDirective(SphinxDirective):
396 | """The ``.. replite::`` directive.
397 |
398 | Adds a replite console to the docs.
399 | """
400 |
401 | has_content = True
402 | required_arguments = 0
403 | option_spec = {
404 | "width": directives.unchanged,
405 | "height": directives.unchanged,
406 | "kernel": directives.unchanged,
407 | "execute": directives.unchanged,
408 | "toolbar": directives.unchanged,
409 | "theme": directives.unchanged,
410 | "prompt": directives.unchanged,
411 | "prompt_color": directives.unchanged,
412 | "search_params": directives.unchanged,
413 | "new_tab": directives.unchanged,
414 | "new_tab_button_text": directives.unchanged,
415 | }
416 |
417 | def run(self):
418 | width = self.options.pop("width", "100%")
419 | height = self.options.pop("height", "100%")
420 |
421 | prompt = self.options.pop("prompt", False)
422 | prompt_color = self.options.pop("prompt_color", None)
423 |
424 | search_params = search_params_parser(self.options.pop("search_params", False))
425 |
426 | # We first check the global config, and then the per-directive
427 | # option. It defaults to True for backwards compatibility.
428 | execute = self.options.pop("execute", str(self.env.config.replite_auto_execute))
429 |
430 | if execute not in ("True", "False"):
431 | raise ValueError("The :execute: option must be either True or False")
432 |
433 | if execute == "False":
434 | self.options["execute"] = "0"
435 |
436 | content = self.content
437 |
438 | button_text = None
439 |
440 | prefix = os.path.relpath(
441 | os.path.join(self.env.app.srcdir, JUPYTERLITE_DIR),
442 | os.path.dirname(self.get_source_info()[0]),
443 | )
444 |
445 | new_tab = self.options.pop("new_tab", False)
446 |
447 | if new_tab:
448 | directive_button_text = self.options.pop("new_tab_button_text", None)
449 | if directive_button_text is not None:
450 | button_text = directive_button_text
451 | else:
452 | button_text = self.env.config.replite_new_tab_button_text
453 | return [
454 | RepliteTab(
455 | prefix=prefix,
456 | width=width,
457 | height=height,
458 | prompt=prompt,
459 | prompt_color=prompt_color,
460 | content=content,
461 | search_params=search_params,
462 | lite_options=self.options,
463 | button_text=button_text,
464 | )
465 | ]
466 |
467 | return [
468 | RepliteIframe(
469 | prefix=prefix,
470 | width=width,
471 | height=height,
472 | prompt=prompt,
473 | prompt_color=prompt_color,
474 | content=content,
475 | search_params=search_params,
476 | lite_options=self.options,
477 | )
478 | ]
479 |
480 |
481 | class _LiteDirective(SphinxDirective):
482 | has_content = False
483 | optional_arguments = 1
484 | final_argument_whitespace = True
485 | option_spec = {
486 | "width": directives.unchanged,
487 | "height": directives.unchanged,
488 | "theme": directives.unchanged,
489 | "prompt": directives.unchanged,
490 | "prompt_color": directives.unchanged,
491 | "search_params": directives.unchanged,
492 | "new_tab": directives.unchanged,
493 | "new_tab_button_text": directives.unchanged,
494 | }
495 |
496 | def _target_is_stale(self, source_path: Path, target_path: Path) -> bool:
497 | # Used as a heuristic to determine if a markdown notebook needs to be
498 | # converted or reconverted to ipynb.
499 | if not target_path.exists():
500 | return True
501 |
502 | return source_path.stat().st_mtime > target_path.stat().st_mtime
503 |
504 | # TODO: Jupytext support many more formats for conversion, but we only
505 | # consider Markdown and IPyNB for now. If we add more formats someday,
506 | # we should also consider them here.
507 | def _assert_no_conflicting_nb_names(
508 | self, source_path: Path, notebooks_dir: Path
509 | ) -> None:
510 | """Check for duplicate notebook names in the documentation sources.
511 | Raises if any notebooks would conflict when converted to IPyNB."""
512 | target_stem = source_path.stem
513 | target_ipynb = f"{target_stem}.ipynb"
514 |
515 | # Only look for conflicts in source directories and among referenced notebooks.
516 | # We do this to prevent conflicts with other files, say, in the "_contents/"
517 | # directory as a result of a previous failed/interrupted build.
518 | if source_path.parent != notebooks_dir:
519 |
520 | # We only consider conflicts if notebooks are actually referenced in
521 | # a directive, to prevent false posiitves from being raised.
522 | if hasattr(self.env, "jupyterlite_notebooks"):
523 | for existing_nb in self.env.jupyterlite_notebooks:
524 | existing_path = Path(existing_nb)
525 | if (
526 | existing_path.stem == target_stem
527 | and existing_path != source_path
528 | ):
529 |
530 | raise RuntimeError(
531 | "All notebooks marked for inclusion with JupyterLite must have a "
532 | f"unique file basename. Found conflict between {source_path} and {existing_path}."
533 | )
534 |
535 | return target_ipynb
536 |
537 | def _strip_notebook_cells(
538 | self, nb: nbformat.NotebookNode
539 | ) -> List[nbformat.NotebookNode]:
540 | """Strip cells based on the presence of the "jupyterlite_sphinx_strip" tag
541 | in the metadata. The content meant to be stripped must be inside its own cell
542 | cell so that the cell itself gets removed from the notebooks. This is so that
543 | we don't end up removing useful data or directives that are not meant to be
544 | removed.
545 |
546 | Parameters
547 | ----------
548 | nb : nbformat.NotebookNode
549 | The notebook object to be stripped.
550 |
551 | Returns
552 | -------
553 | List[nbformat.NotebookNode]
554 | A list of cells that are not meant to be stripped.
555 | """
556 | return [
557 | cell
558 | for cell in nb.cells
559 | if "jupyterlite_sphinx_strip" not in cell.metadata.get("tags", [])
560 | ]
561 |
562 | def run(self):
563 | width = self.options.pop("width", "100%")
564 | height = self.options.pop("height", "1000px")
565 |
566 | prompt = self.options.pop("prompt", False)
567 | prompt_color = self.options.pop("prompt_color", None)
568 |
569 | search_params = search_params_parser(self.options.pop("search_params", False))
570 |
571 | new_tab = self.options.pop("new_tab", False)
572 |
573 | button_text = None
574 |
575 | source_location = os.path.dirname(self.get_source_info()[0])
576 |
577 | prefix = os.path.relpath(
578 | os.path.join(self.env.app.srcdir, JUPYTERLITE_DIR), source_location
579 | )
580 |
581 | if self.arguments:
582 | # Keep track of the notebooks we are going through, so that we don't
583 | # operate on notebooks that are not meant to be included in the built
584 | # docs, i.e., those that have not been referenced in the docs via our
585 | # directives anywhere.
586 | if not hasattr(self.env, "jupyterlite_notebooks"):
587 | self.env.jupyterlite_notebooks = set()
588 |
589 | # As with other directives like literalinclude, an absolute path is
590 | # assumed to be relative to the document root, and a relative path
591 | # is assumed to be relative to the source file
592 | rel_filename, notebook = self.env.relfn2path(self.arguments[0])
593 | self.env.note_dependency(rel_filename)
594 |
595 | notebook_path = Path(notebook)
596 |
597 | self.env.jupyterlite_notebooks.add(str(notebook_path))
598 |
599 | notebooks_dir = Path(self.env.app.srcdir) / CONTENT_DIR
600 | os.makedirs(notebooks_dir, exist_ok=True)
601 |
602 | self._assert_no_conflicting_nb_names(notebook_path, notebooks_dir)
603 | target_name = f"{notebook_path.stem}.ipynb"
604 | target_path = notebooks_dir / target_name
605 |
606 | notebook_is_stripped: bool = self.env.config.strip_tagged_cells
607 |
608 | if notebook_path.suffix.lower() == ".md":
609 | if self._target_is_stale(notebook_path, target_path):
610 | nb = jupytext.read(str(notebook_path))
611 | if notebook_is_stripped:
612 | nb.cells = self._strip_notebook_cells(nb)
613 | with open(target_path, "w", encoding="utf-8") as f:
614 | nbformat.write(nb, f, version=4)
615 |
616 | notebook = str(target_path)
617 | notebook_name = target_name
618 | else:
619 | notebook_name = notebook_path.name
620 | target_path = notebooks_dir / notebook_name
621 |
622 | if notebook_is_stripped:
623 | nb = nbformat.read(notebook, as_version=4)
624 | nb.cells = self._strip_notebook_cells(nb)
625 | nbformat.write(nb, target_path, version=4)
626 | # If notebook_is_stripped is False, then copy the notebook(s) to notebooks_dir.
627 | # If it is True, then they have already been copied to notebooks_dir by the
628 | # nbformat.write() function above.
629 | else:
630 | try:
631 | shutil.copy(notebook, target_path)
632 | except shutil.SameFileError:
633 | pass
634 |
635 | else:
636 | notebook_name = None
637 |
638 | if new_tab:
639 | directive_button_text = self.options.pop("new_tab_button_text", None)
640 | if directive_button_text is not None:
641 | button_text = directive_button_text
642 | else:
643 | # If none, we use the appropriate global config based on
644 | # the type of directive passed.
645 | if isinstance(self, JupyterLiteDirective):
646 | button_text = self.env.config.jupyterlite_new_tab_button_text
647 | elif isinstance(self, NotebookLiteDirective):
648 | button_text = self.env.config.notebooklite_new_tab_button_text
649 | elif isinstance(self, VoiciDirective):
650 | button_text = self.env.config.voici_new_tab_button_text
651 |
652 | return [
653 | self.newtab_cls(
654 | prefix=prefix,
655 | notebook=notebook_name,
656 | width=width,
657 | height=height,
658 | prompt=prompt,
659 | prompt_color=prompt_color,
660 | search_params=search_params,
661 | lite_options=self.options,
662 | button_text=button_text,
663 | )
664 | ]
665 |
666 | return [
667 | self.iframe_cls(
668 | prefix=prefix,
669 | notebook=notebook_name,
670 | width=width,
671 | height=height,
672 | prompt=prompt,
673 | prompt_color=prompt_color,
674 | search_params=search_params,
675 | lite_options=self.options,
676 | )
677 | ]
678 |
679 |
680 | class BaseJupyterViewDirective(_LiteDirective):
681 | """Base class for jupyterlite-sphinx directives."""
682 |
683 | iframe_cls = None # to be defined by subclasses
684 | newtab_cls = None # to be defined by subclasses
685 |
686 | option_spec = {
687 | "width": directives.unchanged,
688 | "height": directives.unchanged,
689 | "theme": directives.unchanged,
690 | "prompt": directives.unchanged,
691 | "prompt_color": directives.unchanged,
692 | "search_params": directives.unchanged,
693 | "new_tab": directives.unchanged,
694 | # "new_tab_button_text" below is useful only if "new_tab" is True, otherwise
695 | # we have "prompt" and "prompt_color" as options already.
696 | "new_tab_button_text": directives.unchanged,
697 | }
698 |
699 |
700 | class JupyterLiteDirective(BaseJupyterViewDirective):
701 | """The ``.. jupyterlite::`` directive.
702 |
703 | Renders a Notebook with JupyterLite in the docs.
704 | """
705 |
706 | iframe_cls = JupyterLiteIframe
707 | newtab_cls = JupyterLiteTab
708 |
709 |
710 | class NotebookLiteDirective(BaseJupyterViewDirective):
711 | """The ``.. notebooklite::`` directive.
712 |
713 | Renders a Notebook with NotebookLite in the docs.
714 | """
715 |
716 | iframe_cls = NotebookLiteIframe
717 | newtab_cls = NotebookLiteTab
718 |
719 |
720 | class VoiciDirective(BaseJupyterViewDirective):
721 | """The ``.. voici::`` directive.
722 |
723 | Renders a Notebook with Voici in the docs.
724 | """
725 |
726 | iframe_cls = VoiciIframe
727 | newtab_cls = VoiciTab
728 |
729 | def run(self):
730 | if voici is None:
731 | raise RuntimeError(
732 | "Voici must be installed if you want to make use of the voici directive: pip install voici"
733 | )
734 |
735 | return super().run()
736 |
737 |
738 | class NotebookLiteParser(RSTParser):
739 | """Sphinx source parser for Jupyter notebooks.
740 |
741 | Shows the Notebook using notebooklite."""
742 |
743 | supported = ("jupyterlite_notebook",)
744 |
745 | def parse(self, inputstring, document):
746 | title = os.path.splitext(os.path.basename(document.current_source))[0]
747 | # Make the "absolute" filename relative to the source root
748 | filename = "/" + os.path.relpath(document.current_source, self.env.app.srcdir)
749 | super().parse(
750 | f"{title}\n{'=' * len(title)}\n.. notebooklite:: {filename}",
751 | document,
752 | )
753 |
754 |
755 | class TryExamplesDirective(SphinxDirective):
756 | """Add button to try doctest examples in Jupyterlite notebook."""
757 |
758 | has_content = True
759 | required_arguments = 0
760 | option_spec = {
761 | "height": directives.unchanged,
762 | "theme": directives.unchanged,
763 | "button_text": directives.unchanged,
764 | "example_class": directives.unchanged,
765 | "warning_text": directives.unchanged,
766 | }
767 |
768 | def run(self):
769 | if "generated_notebooks" not in self.env.temp_data:
770 | self.env.temp_data["generated_notebooks"] = {}
771 |
772 | directive_key = f"{self.env.docname}-{self.lineno}"
773 | notebook_unique_name = self.env.temp_data["generated_notebooks"].get(
774 | directive_key
775 | )
776 |
777 | # Use global configuration values from conf.py in manually inserted directives
778 | # if they are provided and the user has not specified a config value in the
779 | # directive itself.
780 |
781 | default_button_text = self.env.config.try_examples_global_button_text
782 | if default_button_text is None:
783 | default_button_text = "Try it with JupyterLite!"
784 | button_text = self.options.pop("button_text", default_button_text)
785 |
786 | default_warning_text = self.env.config.try_examples_global_warning_text
787 | warning_text = self.options.pop("warning_text", default_warning_text)
788 |
789 | default_example_class = self.env.config.try_examples_global_theme
790 | if default_example_class is None:
791 | default_example_class = ""
792 | example_class = self.options.pop("example_class", default_example_class)
793 |
794 | # A global height cannot be set in conf.py
795 | height = self.options.pop("height", None)
796 |
797 | # We need to get the relative path back to the documentation root from
798 | # whichever file the docstring content is in.
799 | docname = self.env.docname
800 | depth = len(docname.split("/")) - 1
801 | relative_path_to_root = "/".join([".."] * depth)
802 | prefix = os.path.join(relative_path_to_root, JUPYTERLITE_DIR)
803 |
804 | lite_app = "tree/"
805 | notebooks_path = "../notebooks/"
806 |
807 | content_container_node = nodes.container(
808 | classes=["try_examples_outer_container", example_class]
809 | )
810 | examples_div_id = uuid4()
811 | content_container_node["ids"].append(examples_div_id)
812 | # Parse the original content to create nodes
813 | content_node = nodes.container()
814 | content_node["classes"].append("try_examples_content")
815 | self.state.nested_parse(self.content, self.content_offset, content_node)
816 |
817 | if notebook_unique_name is None:
818 | nb = examples_to_notebook(self.content, warning_text=warning_text)
819 | self.content = None
820 | notebooks_dir = Path(self.env.app.srcdir) / CONTENT_DIR
821 | notebook_unique_name = f"{uuid4()}.ipynb".replace("-", "_")
822 | self.env.temp_data["generated_notebooks"][
823 | directive_key
824 | ] = notebook_unique_name
825 | # Copy the Notebook for NotebookLite to find
826 | os.makedirs(notebooks_dir, exist_ok=True)
827 | with open(
828 | notebooks_dir / Path(notebook_unique_name), "w", encoding="utf-8"
829 | ) as f:
830 | # nbf.write incorrectly formats multiline arrays in output.
831 | json.dump(nb, f, indent=4, ensure_ascii=False)
832 |
833 | self.options["path"] = notebook_unique_name
834 | app_path = f"{lite_app}{notebooks_path}"
835 | options = "&".join(
836 | [f"{key}={quote(value)}" for key, value in self.options.items()]
837 | )
838 |
839 | iframe_parent_div_id = uuid4()
840 | iframe_div_id = uuid4()
841 | iframe_src = f'{prefix}/{app_path}{f"index.html?{options}" if options else ""}'
842 |
843 | # Parent container (initially hidden)
844 | iframe_parent_container_div_start = (
845 | f''
847 | )
848 |
849 | iframe_parent_container_div_end = "
"
850 | iframe_container_div = (
851 | f''
853 | f"
"
854 | )
855 |
856 | # Button with the onclick event to swap embedded notebook back to examples.
857 | go_back_button_html = (
858 | '"
862 | )
863 |
864 | full_screen_button_html = (
865 | '"
869 | )
870 |
871 | # Button with the onclick event to swap examples with embedded notebook.
872 | try_it_button_html = (
873 | ''
874 | '"
879 | "
"
880 | )
881 | try_it_button_node = nodes.raw("", try_it_button_html, format="html")
882 |
883 | # Combine everything
884 | notebook_container_html = (
885 | iframe_parent_container_div_start
886 | + ''
887 | + go_back_button_html
888 | + full_screen_button_html
889 | + "
"
890 | + iframe_container_div
891 | + iframe_parent_container_div_end
892 | )
893 | content_container_node += try_it_button_node
894 | content_container_node += content_node
895 |
896 | notebook_container = nodes.raw("", notebook_container_html, format="html")
897 |
898 | # Search config file allowing for config changes without rebuilding docs.
899 | config_path = os.path.join(relative_path_to_root, "try_examples.json")
900 | script_html = (
901 | ""
906 | )
907 | script_node = nodes.raw("", script_html, format="html")
908 |
909 | return [content_container_node, notebook_container, script_node]
910 |
911 |
912 | def _process_docstring_examples(app: Sphinx, docname: str, source: List[str]) -> None:
913 | source_path: os.PathLike = Path(app.env.doc2path(docname))
914 | if source_path.suffix == ".py":
915 | source[0] = insert_try_examples_directive(source[0])
916 |
917 |
918 | def _process_autodoc_docstrings(app, what, name, obj, options, lines):
919 | try_examples_options = {
920 | "theme": app.config.try_examples_global_theme,
921 | "button_text": app.config.try_examples_global_button_text,
922 | "warning_text": app.config.try_examples_global_warning_text,
923 | }
924 | try_examples_options = {
925 | key: value for key, value in try_examples_options.items() if value is not None
926 | }
927 | modified_lines = insert_try_examples_directive(lines, **try_examples_options)
928 | lines.clear()
929 | lines.extend(modified_lines)
930 |
931 |
932 | def conditional_process_examples(app, config):
933 | if config.global_enable_try_examples:
934 | app.connect("source-read", _process_docstring_examples)
935 | app.connect("autodoc-process-docstring", _process_autodoc_docstrings)
936 |
937 |
938 | def inited(app: Sphinx, config):
939 | # Create the content dir
940 | os.makedirs(os.path.join(app.srcdir, CONTENT_DIR), exist_ok=True)
941 |
942 | if (
943 | config.jupyterlite_bind_ipynb_suffix
944 | and ".ipynb" not in config.source_suffix
945 | and ".ipynb" not in app.registry.source_suffix
946 | ):
947 | app.add_source_suffix(".ipynb", "jupyterlite_notebook")
948 |
949 |
950 | def jupyterlite_build(app: Sphinx, error):
951 | if error is not None:
952 | # Do not build JupyterLite
953 | return
954 |
955 | if app.builder.format == "html":
956 | print("[jupyterlite-sphinx] Running JupyterLite build")
957 | jupyterlite_config = app.env.config.jupyterlite_config
958 | jupyterlite_overrides = app.env.config.jupyterlite_overrides
959 | jupyterlite_contents = app.env.config.jupyterlite_contents
960 |
961 | jupyterlite_dir = str(app.env.config.jupyterlite_dir)
962 |
963 | jupyterlite_build_command_options: Dict[str, Any] = (
964 | app.env.config.jupyterlite_build_command_options
965 | )
966 |
967 | config = []
968 | overrides = []
969 | if jupyterlite_config:
970 | config = ["--config", jupyterlite_config]
971 |
972 | if jupyterlite_overrides:
973 | # JupyterLite's build command does not validate the existence
974 | # of the JSON file, so we do it ourselves.
975 | # We will raise a FileNotFoundError if the file does not exist
976 | # in the Sphinx project directory.
977 | overrides_path = Path(app.srcdir) / jupyterlite_overrides
978 | if not Path(overrides_path).exists():
979 | raise FileNotFoundError(
980 | f"Overrides file {overrides_path} does not exist. "
981 | "Please check your configuration."
982 | )
983 |
984 | overrides = ["--settings-overrides", jupyterlite_overrides]
985 |
986 | if jupyterlite_contents is None:
987 | jupyterlite_contents = []
988 | elif isinstance(jupyterlite_contents, str):
989 | jupyterlite_contents = [jupyterlite_contents]
990 |
991 | # Expand globs in the contents strings
992 | contents = []
993 | for pattern in jupyterlite_contents:
994 | pattern_path = Path(pattern)
995 |
996 | base_path = (
997 | pattern_path.parent
998 | if pattern_path.is_absolute()
999 | else Path(app.srcdir) / pattern_path.parent
1000 | )
1001 | glob_pattern = pattern_path.name
1002 |
1003 | matched_paths = base_path.glob(glob_pattern)
1004 |
1005 | for matched_path in matched_paths:
1006 | # If the matched path is absolute, we keep it as is, and
1007 | # if it is relative, we convert it to a path relative to
1008 | # the documentation source directory.
1009 | contents_path = (
1010 | str(matched_path)
1011 | if matched_path.is_absolute()
1012 | else str(matched_path.relative_to(app.srcdir))
1013 | )
1014 |
1015 | contents.extend(["--contents", contents_path])
1016 |
1017 | apps_option = []
1018 | for liteapp in ["notebooks", "edit", "lab", "repl", "tree", "consoles"]:
1019 | apps_option.extend(["--apps", liteapp])
1020 | if voici is not None:
1021 | apps_option.extend(["--apps", "voici"])
1022 |
1023 | command = [
1024 | sys.executable,
1025 | "-m",
1026 | "jupyter",
1027 | "lite",
1028 | "build",
1029 | "--debug",
1030 | *config,
1031 | *overrides,
1032 | *contents,
1033 | "--contents",
1034 | os.path.join(app.srcdir, CONTENT_DIR),
1035 | "--output-dir",
1036 | os.path.join(app.outdir, JUPYTERLITE_DIR),
1037 | *apps_option,
1038 | "--lite-dir",
1039 | jupyterlite_dir,
1040 | ]
1041 |
1042 | if jupyterlite_build_command_options is not None:
1043 | for key, value in jupyterlite_build_command_options.items():
1044 | # Check for conflicting options from the default command we use
1045 | # while building. We don't want to allow these to be overridden
1046 | # unless they are explicitly set through Sphinx config.
1047 | if key in ["contents", "output-dir", "lite-dir"]:
1048 | jupyterlite_command_error_message = f"""
1049 | Additional option, {key}, passed to `jupyter lite build` through
1050 | `jupyterlite_build_command_options` in conf.py is already an existing
1051 | option. "contents", "output_dir", and "lite_dir" can be configured in
1052 | conf.py as described in the jupyterlite-sphinx documentation:
1053 | https://jupyterlite-sphinx.readthedocs.io/en/stable/configuration.html
1054 | """
1055 | raise RuntimeError(jupyterlite_command_error_message)
1056 | command.extend([f"--{key}", str(value)])
1057 |
1058 | assert all(
1059 | [isinstance(s, str) for s in command]
1060 | ), f"Expected all commands arguments to be a str, got {command}"
1061 |
1062 | kwargs: Dict[str, Any] = {}
1063 | if app.env.config.jupyterlite_silence:
1064 | kwargs["stdout"] = subprocess.PIPE
1065 | kwargs["stderr"] = subprocess.PIPE
1066 |
1067 | completed_process: CompletedProcess[bytes] = subprocess.run(
1068 | command, cwd=app.srcdir, check=True, **kwargs
1069 | )
1070 |
1071 | if completed_process.returncode != 0:
1072 | if app.env.config.jupyterlite_silence:
1073 | print(
1074 | "`jupyterlite build` failed but its output has been silenced."
1075 | " stdout and stderr are reproduced below.\n"
1076 | )
1077 | print("stdout:", completed_process.stdout.decode())
1078 | print("stderr:", completed_process.stderr.decode())
1079 |
1080 | # Raise the original exception that would have occurred with check=True
1081 | raise subprocess.CalledProcessError(
1082 | returncode=completed_process.returncode,
1083 | cmd=command,
1084 | output=completed_process.stdout,
1085 | stderr=completed_process.stderr,
1086 | )
1087 |
1088 | print("[jupyterlite-sphinx] JupyterLite build done")
1089 |
1090 | # Cleanup
1091 | try:
1092 | shutil.rmtree(os.path.join(app.srcdir, CONTENT_DIR))
1093 | os.remove(".jupyterlite.doit.db")
1094 | except FileNotFoundError:
1095 | pass
1096 |
1097 |
1098 | def setup(app):
1099 | # Initialize NotebookLite parser
1100 | app.add_source_parser(NotebookLiteParser)
1101 |
1102 | app.connect("config-inited", inited)
1103 | # We need to build JupyterLite at the end, when all the content was created
1104 | app.connect("build-finished", jupyterlite_build)
1105 |
1106 | # Config options
1107 | app.add_config_value("jupyterlite_config", None, rebuild="html")
1108 | app.add_config_value("jupyterlite_overrides", None, rebuild="html")
1109 | app.add_config_value("jupyterlite_dir", str(app.srcdir), rebuild="html")
1110 | app.add_config_value("jupyterlite_contents", None, rebuild="html")
1111 | app.add_config_value("jupyterlite_bind_ipynb_suffix", True, rebuild="html")
1112 | app.add_config_value("jupyterlite_silence", True, rebuild=True)
1113 | app.add_config_value("strip_tagged_cells", False, rebuild=True)
1114 |
1115 | # Pass a dictionary of additional options to the JupyterLite build command
1116 | app.add_config_value("jupyterlite_build_command_options", None, rebuild="html")
1117 |
1118 | app.add_config_value("global_enable_try_examples", default=False, rebuild=True)
1119 | app.add_config_value("try_examples_global_theme", default=None, rebuild=True)
1120 | app.add_config_value("try_examples_global_warning_text", default=None, rebuild=True)
1121 | app.add_config_value(
1122 | "try_examples_global_button_text",
1123 | default=None,
1124 | rebuild="html",
1125 | )
1126 |
1127 | # Allow customising the button text for each directive (this is useful
1128 | # only when "new_tab" is set to True)
1129 | app.add_config_value(
1130 | "jupyterlite_new_tab_button_text", "Open as a notebook", rebuild="html"
1131 | )
1132 | app.add_config_value(
1133 | "notebooklite_new_tab_button_text", "Open as a notebook", rebuild="html"
1134 | )
1135 | app.add_config_value("voici_new_tab_button_text", "Open with Voici", rebuild="html")
1136 | app.add_config_value(
1137 | "replite_new_tab_button_text", "Open in a REPL", rebuild="html"
1138 | )
1139 |
1140 | # Initialize NotebookLite and JupyterLite directives
1141 | app.add_node(
1142 | NotebookLiteIframe,
1143 | html=(visit_element_html, None),
1144 | latex=(skip, None),
1145 | textinfo=(skip, None),
1146 | text=(skip, None),
1147 | man=(skip, None),
1148 | )
1149 | app.add_directive("notebooklite", NotebookLiteDirective)
1150 | # For backward compatibility
1151 | app.add_directive("retrolite", NotebookLiteDirective)
1152 | app.add_node(
1153 | JupyterLiteIframe,
1154 | html=(visit_element_html, None),
1155 | latex=(skip, None),
1156 | textinfo=(skip, None),
1157 | text=(skip, None),
1158 | man=(skip, None),
1159 | )
1160 | for node_class in [NotebookLiteTab, JupyterLiteTab]:
1161 | app.add_node(
1162 | node_class,
1163 | html=(visit_element_html, None),
1164 | latex=(skip, None),
1165 | textinfo=(skip, None),
1166 | text=(skip, None),
1167 | man=(skip, None),
1168 | )
1169 | app.add_directive("jupyterlite", JupyterLiteDirective)
1170 |
1171 | # Initialize Replite directive and tab
1172 | app.add_node(
1173 | RepliteIframe,
1174 | html=(visit_element_html, None),
1175 | latex=(skip, None),
1176 | textinfo=(skip, None),
1177 | text=(skip, None),
1178 | man=(skip, None),
1179 | )
1180 | app.add_node(
1181 | RepliteTab,
1182 | html=(visit_element_html, None),
1183 | latex=(skip, None),
1184 | textinfo=(skip, None),
1185 | text=(skip, None),
1186 | man=(skip, None),
1187 | )
1188 | app.add_directive("replite", RepliteDirective)
1189 | app.add_config_value("replite_auto_execute", True, rebuild="html")
1190 |
1191 | # Initialize Voici directive and tabbed interface
1192 | app.add_node(
1193 | VoiciIframe,
1194 | html=(visit_element_html, None),
1195 | latex=(skip, None),
1196 | textinfo=(skip, None),
1197 | text=(skip, None),
1198 | man=(skip, None),
1199 | )
1200 | app.add_node(
1201 | VoiciTab,
1202 | html=(visit_element_html, None),
1203 | latex=(skip, None),
1204 | textinfo=(skip, None),
1205 | text=(skip, None),
1206 | man=(skip, None),
1207 | )
1208 | app.add_directive("voici", VoiciDirective)
1209 |
1210 | # Initialize TryExamples directive
1211 | app.add_directive("try_examples", TryExamplesDirective)
1212 | app.connect("config-inited", conditional_process_examples)
1213 |
1214 | # CSS and JS assets
1215 | copy_asset(str(HERE / "jupyterlite_sphinx.css"), str(Path(app.outdir) / "_static"))
1216 | copy_asset(str(HERE / "jupyterlite_sphinx.js"), str(Path(app.outdir) / "_static"))
1217 |
1218 | app.add_css_file("https://fonts.googleapis.com/css?family=Vibur")
1219 | app.add_css_file("jupyterlite_sphinx.css")
1220 |
1221 | app.add_js_file("jupyterlite_sphinx.js")
1222 |
1223 | # Copy optional try examples runtime config if it exists.
1224 | try_examples_config_path = Path(app.srcdir) / "try_examples.json"
1225 | if try_examples_config_path.exists():
1226 | copy_asset(str(try_examples_config_path), app.outdir)
1227 |
1228 | return {"parallel_read_safe": True}
1229 |
1230 |
1231 | def search_params_parser(search_params: str) -> str:
1232 | pattern = re.compile(r"^\[(?:\s*[\"']{1}([^=\s\,&=\?\/]+)[\"']{1}\s*\,?)+\]$")
1233 | if not search_params:
1234 | return "false"
1235 | if search_params in ["True", "False"]:
1236 | return search_params.lower()
1237 | elif pattern.match(search_params):
1238 | return search_params.replace('"', "'")
1239 | else:
1240 | raise ValueError(
1241 | 'The search_params directive must be either True, False or ["param1", "param2"].\n'
1242 | 'The params name shouldn\'t contain any of the following characters ["\\", "\'", """, ",", "?", "=", "&", " ").'
1243 | )
1244 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "jupyterlite-sphinx"
7 | dynamic = ["version"]
8 | description = "Sphinx extension for deploying JupyterLite"
9 | readme = "README.md"
10 | license = { file = "LICENSE" }
11 | requires-python = ">=3.9"
12 | authors = [
13 | { name = "JupyterLite Contributors" },
14 | ]
15 | dependencies = [
16 | "docutils",
17 | "jupyter_server",
18 | "jupyterlab_server",
19 | "jupyterlite-core >=0.2,<0.7",
20 | "jupytext",
21 | "nbformat",
22 | "sphinx>=4",
23 | ]
24 |
25 | [project.optional-dependencies]
26 | dev = [
27 | "hatch",
28 | ]
29 |
30 | docs = [
31 | "myst_parser",
32 | "pydata-sphinx-theme",
33 | "jupyterlite-xeus>=0.1.8,<4",
34 | ]
35 |
36 | [tool.hatch.version]
37 | path = "jupyterlite_sphinx/__init__.py"
38 |
39 | [tool.hatch.build.targets.sdist]
40 | include = [
41 | "/jupyterlite_sphinx",
42 | ]
43 |
44 | [tool.hatch.envs.docs]
45 | features = ["docs"]
46 | [tool.hatch.envs.docs.scripts]
47 | build = "sphinx-build -W -b html docs docs/build/html"
48 | serve = "python -m http.server --directory docs/build/html"
49 |
50 | [[tool.mypy.overrides]]
51 | module = [
52 | "voici",
53 | ]
54 | ignore_missing_imports = true
55 |
--------------------------------------------------------------------------------