"""Custom Sphinx directives."""
from __future__ import annotations
import os
import shutil
from collections import namedtuple
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Any, List
from docutils import nodes, statemachine
from docutils.parsers.rst import Directive, directives
from docutils.parsers.rst.directives import images
from sphinx.errors import ExtensionError
from sphinx.util.logging import getLogger
from .backreferences import (
THUMBNAIL_PARENT_DIV,
THUMBNAIL_PARENT_DIV_CLOSE,
_thumbnail_div,
)
from .gen_rst import extract_intro_and_title
from .py_source_parser import split_code_and_text_blocks
from .utils import _read_json
if TYPE_CHECKING:
import sphinx.application
import sphinx.config
logger = getLogger("sphinx-gallery")
[docs]
class MiniGallery(Directive):
"""Custom directive to insert a mini-gallery.
The required argument is one or more of the following:
* fully qualified names of objects
* pathlike strings to example Python files
* glob-style pathlike strings to example Python files
The string list of arguments is separated by spaces.
The mini-gallery will be the subset of gallery
examples that make use of that object from that specific namespace
Options:
* `add-heading` adds a heading to the mini-gallery. If an argument is
provided, it uses that text for the heading. Otherwise, it uses
default text.
* `heading-level` specifies the heading level of the heading as a single
character. If omitted, the default heading level is `'^'`.
"""
required_arguments = 0
has_content = True
optional_arguments = 1
final_argument_whitespace = True
option_spec = {
"add-heading": directives.unchanged,
"heading-level": directives.single_char_or_unicode,
}
def _get_target_dir(
self,
config: sphinx.config.Config,
src_dir: str,
path: Path,
obj: str,
) -> Path:
"""Get thumbnail target directory, errors when not in example dir."""
examples_dirs = config.sphinx_gallery_conf["examples_dirs"]
if not isinstance(examples_dirs, list):
examples_dirs = [examples_dirs]
gallery_dirs = config.sphinx_gallery_conf["gallery_dirs"]
if not isinstance(gallery_dirs, list):
gallery_dirs = [gallery_dirs]
gal_matches = []
for e, g in zip(examples_dirs, gallery_dirs):
e = Path(src_dir, e).resolve(strict=True)
g = Path(src_dir, g).resolve(strict=True)
try:
gal_matches.append((path.relative_to(e), g))
except ValueError:
continue
# `path` inside one `examples_dirs`
if (n_match := len(gal_matches)) == 1:
ex_parents, target_dir = (
gal_matches[0][0].parents,
gal_matches[0][1],
)
# `path` inside several `examples_dirs`, take the example dir with
# longest match (shortest parents after relative)
elif n_match > 1:
ex_parents, target_dir = min(
[(match[0].parents, match[1]) for match in gal_matches],
key=lambda x: len(x[0]),
)
# `path` is not inside a `examples_dirs`
else:
raise ExtensionError(
f"minigallery directive error: path input '{obj}' found file:"
f" '{path}' but this does not live inside any examples_dirs: "
f"{examples_dirs}"
)
# Add subgallery path, if present
subdir: str | Path = ""
if (ex_p := ex_parents[0]) != Path("."):
subdir = ex_p
target_dir = target_dir / subdir
return target_dir
[docs]
def run(self) -> list[Any]:
"""Generate mini-gallery from backreference and example files."""
from .gen_rst import _get_callables
if not (self.arguments or self.content):
raise ExtensionError("No arguments passed to 'minigallery'")
# Respect the same disabling options as the `raw` directive
if (
not self.state.document.settings.raw_enabled
or not self.state.document.settings.file_insertion_enabled
):
raise self.warning(f'"{self.name}" directive disabled.')
# Retrieve the backreferences directory
config = self.state.document.settings.env.config
backreferences_dir = config.sphinx_gallery_conf["backreferences_dir"]
if backreferences_dir is None:
logger.warning(
"'backreferences_dir' config is None, minigallery "
"directive will resolve all inputs as file paths or globs."
)
# Retrieve source directory
src_dir = config.sphinx_gallery_conf["src_dir"]
# Parse the argument into the individual args
arg_list: list[str] = []
if self.arguments:
arg_list.extend([c.strip() for c in self.arguments[0].split()])
if self.content:
arg_list.extend([c.strip() for c in self.content])
lines = []
# Add a heading if requested
if "add-heading" in self.options:
heading = self.options["add-heading"]
if heading == "":
if len(arg_list) == 1:
heading = f"Examples using ``{arg_list[0]}``"
else:
heading = "Examples using one of multiple objects"
lines.append(heading)
heading_level = self.options.get("heading-level", "^")
lines.append(heading_level * len(heading))
ExampleInfo = namedtuple("ExampleInfo", ["target_dir", "intro", "title", "arg"])
backreferences_all = None
if backreferences_dir:
backreferences_all = _read_json(
Path(src_dir, backreferences_dir, "backreferences_all.json")
)
def has_backrefs(arg: str) -> Any:
if backreferences_all is None:
return False
examples = backreferences_all.get(arg, None)
if examples:
return examples
return False
# Full paths to example files are dict keys, preventing duplicates
file_paths = {}
for arg in arg_list:
# Backreference arg input
if examples := has_backrefs(arg):
# Note `ExampleInfo.arg` not required for backreference paths
for path in examples:
file_paths[Path(path[1], path[0]).resolve()] = ExampleInfo(
target_dir=path[2], intro=path[3], title=path[4], arg=None
)
# Glob path arg input
elif paths := Path(src_dir).glob(arg):
# Glob paths require extra parsing to get the intro and title
# so we don't want to override a duplicate backreference arg input
for path in paths:
path_resolved = path.resolve()
if path_resolved in file_paths:
continue
else:
file_paths[path_resolved] = ExampleInfo(None, None, None, arg)
if len(file_paths) == 0:
return []
lines.append(THUMBNAIL_PARENT_DIV)
# sort on the file path
if config.sphinx_gallery_conf["minigallery_sort_order"] is None:
sortkey = None
else:
(sortkey,) = _get_callables(
config.sphinx_gallery_conf, "minigallery_sort_order"
)
for path, path_info in sorted(
file_paths.items(),
# `x[0]` to sort on key only
key=((lambda x: sortkey(str(x[0]))) if sortkey else None),
):
if path_info.intro is not None:
thumbnail = _thumbnail_div(
path_info.target_dir,
src_dir,
path.name,
path_info.intro,
path_info.title,
)
else:
target_dir = self._get_target_dir(config, src_dir, path, path_info.arg)
# Get thumbnail
# TODO: ideally we would not need to parse file (again) here
_, script_blocks = split_code_and_text_blocks(
str(path), return_node=False
)
intro, title = extract_intro_and_title(
str(path), script_blocks[0].content
)
thumbnail = _thumbnail_div(target_dir, src_dir, path.name, intro, title)
lines.append(thumbnail)
lines.append(THUMBNAIL_PARENT_DIV_CLOSE)
text = "\n".join(lines)
include_lines = statemachine.string2lines(text, convert_whitespace=True)
self.state_machine.insert_input(include_lines, str(path))
return []
"""
Image sg for responsive images
"""
[docs]
class imgsgnode(nodes.General, nodes.Element):
"""Sphinx Gallery image node class."""
pass
[docs]
class ImageSg(images.Image):
"""Implements a directive to allow an optional hidpi image.
Meant to be used with the `image_srcset` configuration option.
e.g.::
.. image-sg:: /plot_types/basic/images/sphx_glr_bar_001.png
:alt: bar
:srcset: /plot_types/basic/images/sphx_glr_bar_001.png,
/plot_types/basic/images/sphx_glr_bar_001_2_00x.png 2.00x
:class: sphx-glr-single-img
The resulting html is::
<img src="sphx_glr_bar_001_hidpi.png"
srcset="_images/sphx_glr_bar_001.png,
_images/sphx_glr_bar_001_2_00x.png 2x",
alt="bar"
class="sphx-glr-single-img" />
"""
has_content = False
required_arguments = 1
optional_arguments = 3
final_argument_whitespace = False
option_spec = {
"srcset": directives.unchanged,
"class": directives.class_option,
"alt": directives.unchanged,
}
[docs]
def run(self) -> List[nodes.Node]:
"""Update node contents."""
image_node = imgsgnode()
imagenm = self.arguments[0]
image_node["alt"] = self.options.get("alt", "")
image_node["class"] = self.options.get("class", None)
# we would like uri to be the highest dpi version so that
# latex etc will use that. But for now, lets just make
# imagenm
image_node["uri"] = imagenm
image_node["srcset"] = self.options.get("srcset", None)
return [image_node]
def _parse_srcset(st: str) -> dict[float, str]:
"""Parse st."""
entries = st.split(",")
srcset: dict[float, str] = {}
for entry in entries:
spl = entry.strip().split(" ")
if len(spl) == 1:
srcset[0] = spl[0]
elif len(spl) == 2:
mult = spl[1][:-1]
srcset[float(mult)] = spl[0]
else:
raise ExtensionError('srcset argument "{entry}" is invalid.')
return srcset
[docs]
def visit_imgsg_html(self, node: imgsgnode) -> None:
"""Handle HTML image tag depending on 'srcset' configuration.
If 'srcset' is not `None`, copy images, generate image html tag with 'srcset'
and add to HTML `body`. If 'srcset' is `None` run `visit_image` on `node`.
"""
if node["srcset"] is None:
self.visit_image(node)
return
imagedir, srcset = _copy_images(self, node)
# /doc/examples/subd/plot_1.rst
docsource = self.document["source"]
# /doc/
# make sure to add the trailing slash:
srctop = os.path.join(self.builder.srcdir, "")
# examples/subd/plot_1.rst
relsource = os.path.relpath(docsource, srctop)
# /doc/build/html
desttop = os.path.join(self.builder.outdir, "")
# /doc/build/html/examples/subd
dest = os.path.join(desttop, relsource)
# ../../_images/ for dirhtml and ../_images/ for html
imagerel = os.path.relpath(imagedir, os.path.dirname(dest))
if self.builder.name == "dirhtml":
imagerel = os.path.join("..", imagerel, "")
else: # html
imagerel = os.path.join(imagerel, "")
if "\\" in imagerel:
imagerel = imagerel.replace("\\", "/")
# make srcset str. Need to change all the prefixes!
srcsetst = ""
for mult in srcset:
nm = os.path.basename(srcset[mult][1:])
# ../../_images/plot_1_2_0x.png
relpath = imagerel + nm
srcsetst += f"{relpath}"
if mult == 0:
srcsetst += ", "
else:
srcsetst += f" {mult:1.2f}x, "
# trim trailing comma and space...
srcsetst = srcsetst[:-2]
# make uri also be relative...
nm = os.path.basename(node["uri"][1:])
uri = imagerel + nm
alt = node["alt"]
if node["class"] is not None:
classst = node["class"][0]
classst = f'class = "{classst}"'
else:
classst = ""
html_block = f'<img src="{uri}" srcset="{srcsetst}" alt="{alt}"' + f" {classst}/>"
self.body.append(html_block)
[docs]
def visit_imgsg_latex(self, node: imgsgnode) -> None:
"""Copy images, set node[uri] to highest resolution image and call `visit_image`."""
if node["srcset"] is not None:
imagedir, srcset = _copy_images(self, node)
maxmult: float = -1
# choose the highest res version for latex:
for key in srcset.keys():
maxmult = max(maxmult, key)
node["uri"] = str(PurePosixPath(srcset[maxmult]).name)
self.visit_image(node)
def _copy_images(self, node: imgsgnode) -> tuple[PurePosixPath, dict[float, str]]:
srcset = _parse_srcset(node["srcset"])
# where the sources are. i.e. myproj/source
srctop = self.builder.srcdir
# copy image from source to imagedir. This is
# *probably* supposed to be done by a builder but...
# ie myproj/build/html/_images
imagedir = PurePosixPath(self.builder.outdir, self.builder.imagedir)
os.makedirs(imagedir, exist_ok=True)
# copy all the sources to the imagedir:
for mult in srcset:
abspath = PurePosixPath(srctop, srcset[mult][1:])
shutil.copyfile(abspath, imagedir / abspath.name)
return imagedir, srcset
[docs]
def depart_imgsg_html(self, node: imgsgnode) -> None:
"""HTML depart node visitor function."""
pass
[docs]
def depart_imgsg_latex(self, node: imgsgnode) -> None:
"""LaTeX depart node visitor function."""
self.depart_image(node)
[docs]
def imagesg_addnode(app: sphinx.application.Sphinx) -> None:
"""Add `imgsgnode` to Sphinx app with visitor functions for HTML and LaTeX."""
app.add_node(
imgsgnode,
html=(visit_imgsg_html, depart_imgsg_html),
latex=(visit_imgsg_latex, depart_imgsg_latex),
)