sklearn/doc/sphinxext/move_gallery_links.py

194 lines
8.9 KiB
Python
Raw Permalink Normal View History

2024-08-05 09:32:03 +02:00
"""
This script intends to better integrate sphinx-gallery into pydata-sphinx-theme. In
particular, it moves the download links and badge links in the footer of each generated
example page into the secondary sidebar, then removes the footer and the top note
pointing to the footer.
The download links are for Python source code and Jupyter notebook respectively, and
the badge links are for JupyterLite and Binder.
Currently this is achieved via post-processing the HTML generated by sphinx-gallery.
This hack can be removed if the following upstream issue is resolved:
https://github.com/sphinx-gallery/sphinx-gallery/issues/1258
"""
from pathlib import Path
from bs4 import BeautifulSoup
from sphinx.util.display import status_iterator
from sphinx.util.logging import getLogger
logger = getLogger(__name__)
def move_gallery_links(app, exception):
if exception is not None:
return
for gallery_dir in app.config.sphinx_gallery_conf["gallery_dirs"]:
html_gallery_dir = Path(app.builder.outdir, gallery_dir)
# Get all gallery example files to be tweaked; tuples (file, docname)
flat = []
for file in html_gallery_dir.rglob("*.html"):
if file.name in ("index.html", "sg_execution_times.html"):
# These are not gallery example pages, skip
continue
# Extract the documentation name from the path
docname = file.relative_to(app.builder.outdir).with_suffix("").as_posix()
if docname in app.config.html_context["redirects"]:
# This is a redirected page, skip
continue
if docname not in app.project.docnames:
# This should not happen, warn
logger.warning(f"Document {docname} not found but {file} exists")
continue
flat.append((file, docname))
for html_file, _ in status_iterator(
flat,
length=len(flat),
summary="Tweaking gallery links... ",
verbosity=app.verbosity,
stringify_func=lambda x: x[1], # display docname
):
with html_file.open("r", encoding="utf-8") as f:
html = f.read()
soup = BeautifulSoup(html, "html.parser")
# Find the secondary sidebar; it should exist in all gallery example pages
secondary_sidebar = soup.find("div", class_="sidebar-secondary-items")
if secondary_sidebar is None:
logger.warning(f"Secondary sidebar not found in {html_file}")
continue
def _create_secondary_sidebar_component(items):
"""Create a new component in the secondary sidebar.
`items` should be a list of dictionaries with "element" being the bs4
tag of the component and "title" being the title (None if not needed).
"""
component = soup.new_tag("div", **{"class": "sidebar-secondary-item"})
for item in items:
item_wrapper = soup.new_tag("div")
item_wrapper.append(item["element"])
if item["title"]:
item_wrapper["title"] = item["title"]
component.append(item_wrapper)
secondary_sidebar.append(component)
def _create_download_link(link, is_jupyter=False):
"""Create a download link to be appended to a component.
`link` should be the bs4 tag of the original download link, either for
the Python source code (is_jupyter=False) of for the Jupyter notebook
(is_jupyter=True). `link` will not be removed; instead the whole
footnote would be removed where `link` is located.
This returns a dictionary with "element" being the bs4 tag of the new
download link and "title" being the name of the file to download.
"""
new_link = soup.new_tag("a", href=link["href"], download="")
# Place a download icon at the beginning of the new link
download_icon = soup.new_tag("i", **{"class": "fa-solid fa-download"})
new_link.append(download_icon)
# Create the text of the new link; it is shortend to fit better into
# the secondary sidebar. The leading space before "Download ..." is
# intentional to create a small gap between the icon and the text,
# being consistent with the other pydata-sphinx-theme components
link_type = "Jupyter notebook" if is_jupyter else "source code"
new_text = soup.new_string(f" Download {link_type}")
new_link.append(new_text)
# Get the file name to download and use it as the title of the new link
# which will show up when hovering over the link; the file name is
# expected to be in the last span of `link`
link_spans = link.find_all("span")
title = link_spans[-1].text if link_spans else None
return {"element": new_link, "title": title}
def _create_badge_link(link):
"""Create a badge link to be appended to a component.
`link` should be the bs4 tag of the original badge link, either for
binder or JupyterLite. `link` will not be removed; instead the whole
footnote would be removed where `link` is located.
This returns a dictionary with "element" being the bs4 tag of the new
download link and "title" being `None` (no need).
"""
new_link = soup.new_tag("a", href=link["href"])
# The link would essentially be an anchor wrapper outside the image of
# the badge; we get the src and alt attributes by finding the original
# image and limit the height to 20px (fixed) so that the secondary
# sidebar will appear neater
badge_img = link.find("img")
new_img = soup.new_tag(
"img", src=badge_img["src"], alt=badge_img["alt"], height=20
)
new_link.append(new_img)
return {"element": new_link, "title": None}
try:
# `sg_note` is the "go to the end" note at the top of the page
# `sg_footer` is the footer with the download links and badge links
# These will be removed at the end if new links are successfully created
sg_note = soup.find("div", class_="sphx-glr-download-link-note")
sg_footer = soup.find("div", class_="sphx-glr-footer")
# If any one of these two is not found, we directly give up tweaking
if sg_note is None or sg_footer is None:
continue
# Move the download links into the secondary sidebar
py_link_div = sg_footer.find("div", class_="sphx-glr-download-python")
ipy_link_div = sg_footer.find("div", class_="sphx-glr-download-jupyter")
_create_secondary_sidebar_component(
[
_create_download_link(py_link_div.a, is_jupyter=False),
_create_download_link(ipy_link_div.a, is_jupyter=True),
]
)
# Move the badge links into the secondary sidebar
lite_link_div = sg_footer.find("div", class_="lite-badge")
binder_link_div = sg_footer.find("div", class_="binder-badge")
_create_secondary_sidebar_component(
[
_create_badge_link(lite_link_div.a),
_create_badge_link(binder_link_div.a),
]
)
# Remove the sourcelink component from the secondary sidebar; the reason
# we do not remove it by configuration is that we need the secondary
# sidebar to be present for this script to work, while in-page toc alone
# could have been empty
sourcelink = secondary_sidebar.find("div", class_="sourcelink")
if sourcelink is not None:
sourcelink.parent.extract() # because sourcelink has a wrapper div
# Remove the the top note and the whole footer
sg_note.extract()
sg_footer.extract()
except Exception:
# If any step fails we directly skip the file
continue
# Write the modified file back
with html_file.open("w", encoding="utf-8") as f:
f.write(str(soup))
def setup(app):
# Default priority is 500 which sphinx-gallery uses for its build-finished events;
# we need a larger priority to run after sphinx-gallery (larger is later)
app.connect("build-finished", move_gallery_links, priority=900)