diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 7012dd3c5..9e4cc8877 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -39,6 +39,7 @@ jobs:
# run our test suite on various combinations of OS / Python / Sphinx version
run-pytest:
+ needs: [lint]
strategy:
fail-fast: false
matrix:
@@ -93,6 +94,7 @@ jobs:
# Build our site on the 3 major OSes and check for Sphinx warnings
build-site:
+ needs: [lint]
strategy:
fail-fast: false
matrix:
@@ -120,6 +122,7 @@ jobs:
# Run local Lighthouse audit against built site
audit:
+ needs: [build-site]
strategy:
matrix:
os: [ubuntu-latest]
@@ -163,6 +166,7 @@ jobs:
# Generate a profile of the code and upload as an artifact
profile:
+ needs: [build-site, run-pytest]
strategy:
matrix:
os: [ubuntu-latest]
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 466efae7c..1b5e3aa41 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,22 +17,10 @@ repos:
hooks:
- id: black
- - repo: https://github.com/PyCQA/flake8
- rev: 6.0.0
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
+ rev: "v0.0.215"
hooks:
- - id: flake8
- additional_dependencies: [Flake8-pyproject]
-
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.4.0
- hooks:
- - id: check-builtin-literals
- - id: check-case-conflict
- - id: check-toml
- - id: check-yaml
- - id: debug-statements
- - id: end-of-file-fixer
- - id: trailing-whitespace
+ - id: ruff
- repo: https://github.com/asottile/pyupgrade
rev: v3.3.1
diff --git a/docs/scripts/gallery_directive/__init__.py b/docs/_extension/gallery_directive.py
similarity index 91%
rename from docs/scripts/gallery_directive/__init__.py
rename to docs/_extension/gallery_directive.py
index 2119d9872..e80d2c8e7 100644
--- a/docs/scripts/gallery_directive/__init__.py
+++ b/docs/_extension/gallery_directive.py
@@ -8,14 +8,15 @@
It currently exists for maintainers of the pydata-sphinx-theme,
but might be abstracted into a standalone package if it proves useful.
"""
-from yaml import safe_load
-from typing import List
from pathlib import Path
+from typing import Any, Dict, List
from docutils import nodes
from docutils.parsers.rst import directives
-from sphinx.util.docutils import SphinxDirective
+from sphinx.application import Sphinx
from sphinx.util import logging
+from sphinx.util.docutils import SphinxDirective
+from yaml import safe_load
logger = logging.getLogger(__name__)
@@ -142,3 +143,19 @@ def run(self) -> List[nodes.Node]:
if self.options.get("container-class", []):
container.attributes["classes"] += self.options.get("class", [])
return [container]
+
+
+def setup(app: Sphinx) -> Dict[str, Any]:
+ """Add custom configuration to sphinx app.
+
+ Args:
+ app: the Sphinx application
+ Returns:
+ the 2 parallel parameters set to ``True``.
+ """
+ app.add_directive("gallery-grid", GalleryDirective)
+
+ return {
+ "parallel_read_safe": True,
+ "parallel_write_safe": True,
+ }
diff --git a/docs/conf.py b/docs/conf.py
index 162d8f005..f33cf1eb3 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,10 +1,20 @@
+"""Configuration file for the Sphinx documentation builder.
+
+This file only contains a selection of the most common options. For a full
+list see the documentation:
+https://www.sphinx-doc.org/en/master/usage/configuration.html
+"""
+
# -- Path setup --------------------------------------------------------------
import os
import sys
+from pathlib import Path
+from typing import Any, Dict
+
import pydata_sphinx_theme
+from sphinx.application import Sphinx
-sys.path.append("scripts")
-from gallery_directive import GalleryDirective
+sys.path.append(str(Path(".").resolve()))
# -- Project information -----------------------------------------------------
@@ -22,6 +32,7 @@
"sphinxext.rediraffe",
"sphinx_design",
"sphinx_copybutton",
+ "_extension.gallery_directive",
# For extension examples and demos
"ablog",
"jupyter_sphinx",
@@ -57,7 +68,6 @@
autosummary_generate = True
-
# -- Internationalization ----------------------------------------------------
# specifying the natural language populates some key tags
@@ -223,9 +233,13 @@
# -- application setup -------------------------------------------------------
-def setup_to_main(app, pagename, templatename, context, doctree):
+def setup_to_main(
+ app: Sphinx, pagename: str, templatename: str, context, doctree
+) -> None:
+ """Add a function that jinja can access for returning an "edit this page" link pointing to `main`."""
+
def to_main(link: str) -> str:
- """Transform "edit on github" links and make sure they always point to the main branch
+ """Transform "edit on github" links and make sure they always point to the main branch.
Args:
link: the link to the github edit interface
@@ -240,6 +254,17 @@ def to_main(link: str) -> str:
context["to_main"] = to_main
-def setup(app):
- app.add_directive("gallery-grid", GalleryDirective)
+def setup(app: Sphinx) -> Dict[str, Any]:
+ """Add custom configuration to sphinx app.
+
+ Args:
+ app: the Sphinx application
+ Returns:
+ the 2 parralel parameters set to ``True``.
+ """
app.connect("html-page-context", setup_to_main)
+
+ return {
+ "parallel_read_safe": True,
+ "parallel_write_safe": True,
+ }
diff --git a/docs/examples/test_py_module/__init__.py b/docs/examples/test_py_module/__init__.py
index e69de29bb..a2ee367d8 100644
--- a/docs/examples/test_py_module/__init__.py
+++ b/docs/examples/test_py_module/__init__.py
@@ -0,0 +1 @@
+"""Package definition empty file."""
diff --git a/docs/examples/test_py_module/test.py b/docs/examples/test_py_module/test.py
index c76f978d0..800930749 100644
--- a/docs/examples/test_py_module/test.py
+++ b/docs/examples/test_py_module/test.py
@@ -2,7 +2,6 @@
class Foo:
-
"""Docstring for class Foo.
This text tests for the formatting of docstrings generated from output
@@ -65,7 +64,6 @@ def add(self, val1, val2):
:rtype: int
"""
-
return val1 + val2
def capitalize(self, myvalue):
@@ -76,12 +74,10 @@ def capitalize(self, myvalue):
:rtype: string
"""
-
return myvalue.upper()
def another_function(self, a, b, **kwargs):
- """
- Here is another function.
+ """Here is another function.
:param a: The number of green hats you own.
:type a: int
diff --git a/docs/scripts/generate_collaborators_gallery.py b/docs/scripts/generate_collaborators_gallery.py
index d0437cc39..064098efd 100644
--- a/docs/scripts/generate_collaborators_gallery.py
+++ b/docs/scripts/generate_collaborators_gallery.py
@@ -1,14 +1,13 @@
-"""Uses the GitHub API to list a gallery of all people with direct access
-to the repository.
-"""
+"""Uses the GitHub API to list a gallery of all people with direct access to the repository."""
-from yaml import dump
-from subprocess import run
-import shlex
import json
+import shlex
from pathlib import Path
+from subprocess import run
+
+from yaml import dump
-COLLABORATORS_API = "https://api.github.com/repos/pydata/pydata-sphinx-theme/collaborators?affiliation=direct" # noqa
+COLLABORATORS_API = "https://api.github.com/repos/pydata/pydata-sphinx-theme/collaborators?affiliation=direct"
print("Grabbing latest collaborators with GitHub API via GitHub's CLI...")
out = run(shlex.split(f"gh api {COLLABORATORS_API}"), capture_output=True)
diff --git a/docs/scripts/generate_gallery_images.py b/docs/scripts/generate_gallery_images.py
index 7ea15dcce..d2de76188 100644
--- a/docs/scripts/generate_gallery_images.py
+++ b/docs/scripts/generate_gallery_images.py
@@ -1,24 +1,21 @@
-"""
-Use playwright to build a gallery of website using this theme
-"""
+"""Use playwright to build a gallery of website using this theme."""
from pathlib import Path
-from yaml import safe_load
from shutil import copy
-from playwright.sync_api import sync_playwright, TimeoutError
-from rich.progress import track
+
+from playwright.sync_api import TimeoutError, sync_playwright
from rich import print
+from rich.progress import track
+from yaml import safe_load
-def regenerate_gallery():
- """
- Regenerate images of snapshots for our gallery.
+def regenerate_gallery() -> None:
+ """Regenerate images of snapshots for our gallery.
This function should only be triggered in RTD builds as it increases the build
time by 30-60s. Developers can still execute this function from time to time to
populate their local gallery images with updated files.
"""
-
# get the existing folders path
_static_dir = Path(__file__).parents[1] / "_static"
diff --git a/docs/scripts/update_kitchen_sink.py b/docs/scripts/update_kitchen_sink.py
index cb855c92c..230f79fa9 100644
--- a/docs/scripts/update_kitchen_sink.py
+++ b/docs/scripts/update_kitchen_sink.py
@@ -1,5 +1,7 @@
-from urllib.request import urlopen
+"""Script run to update the kitchen sink from https://sphinx-themes.org."""
+
from pathlib import Path
+from urllib.request import urlopen
EXTRA_MESSAGE = """\
.. note::
@@ -11,7 +13,7 @@
:color: primary
Go to Sphinx Themes
-""" # noqa
+"""
kitchen_sink_files = [
"admonitions.rst",
@@ -29,7 +31,7 @@
path_sink = Path(__file__).parent.parent / "examples" / "kitchen-sink"
for ifile in kitchen_sink_files:
print(f"Reading {ifile}...")
- url = f"https://github.com/sphinx-themes/sphinx-themes.org/raw/master/sample-docs/kitchen-sink/{ifile}" # noqa
+ url = f"https://github.com/sphinx-themes/sphinx-themes.org/raw/master/sample-docs/kitchen-sink/{ifile}"
text = urlopen(url).read().decode()
# The sphinx-themes docs expect Furo to be installed, so we overwrite w/ PST
text = text.replace("src/furo", "src/pydata_sphinx_theme")
diff --git a/noxfile.py b/noxfile.py
index c188f2362..71903094f 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -1,19 +1,23 @@
"""Automatically build our documentation or run tests.
Environments are re-used by default.
-
Re-install the environment from scratch:
nox -s docs -- -r
"""
-import nox
+import shutil as sh
+import tempfile
from pathlib import Path
from shlex import split
+from textwrap import dedent
+
+import nox
nox.options.reuse_existing_virtualenvs = True
+ROOT = Path(__file__).parent
-def _should_install(session):
+def _should_install(session: nox.Session) -> bool:
"""Decide if we should install an environment or if it already exists.
This speeds up the local install considerably because building the wheel
@@ -21,6 +25,9 @@ def _should_install(session):
We assume that if `sphinx-build` is in the bin/ path, the environment is
installed.
+
+ Parameter:
+ session: the current nox session
"""
if session.bin_paths is None:
session.log("Running with `--no-venv` so don't install anything...")
@@ -36,20 +43,25 @@ def _should_install(session):
return should_install
-def _compile_translations(session):
- session.run(*split("pybabel compile -d src/pydata_sphinx_theme/locale -D sphinx"))
+@nox.session(reuse_venv=True)
+def lint(session: nox.Session) -> None:
+ """Check the themes pre-commit before any other session."""
+ session.install("pre-commit")
+ session.run("pre-commit", "run", "-a")
-@nox.session(name="compile")
-def compile(session):
+@nox.session()
+def compile(session: nox.Session) -> None:
"""Compile the theme's web assets with sphinx-theme-builder."""
if _should_install(session):
session.install("-e", ".")
session.install("sphinx-theme-builder[cli]")
+ session.run("stb", "compile")
+
-@nox.session(name="docs")
-def docs(session):
+@nox.session()
+def docs(session: nox.Session) -> None:
"""Build the documentation and place in docs/_build/html. Use --no-compile to skip compilation."""
if _should_install(session):
session.install("-e", ".[doc]")
@@ -60,27 +72,27 @@ def docs(session):
@nox.session(name="docs-live")
-def docs_live(session):
+def docs_live(session: nox.Session) -> None:
"""Build the docs with a live server that re-loads as you make changes."""
- _compile_translations(session)
+ session.run(*split("pybabel compile -d src/pydata_sphinx_theme/locale -D sphinx"))
if _should_install(session):
session.install("-e", ".[doc]")
session.install("sphinx-theme-builder[cli]")
session.run("stb", "serve", "docs", "--open-browser")
-@nox.session(name="test")
-def test(session):
+@nox.session()
+def test(session: nox.Session) -> None:
"""Run the test suite."""
if _should_install(session):
session.install("-e", ".[test]")
- _compile_translations(session)
+ session.run(*split("pybabel compile -d src/pydata_sphinx_theme/locale -D sphinx"))
session.run("pytest", *session.posargs)
@nox.session(name="test-sphinx")
@nox.parametrize("sphinx", ["4", "5", "6"])
-def test_sphinx(session, sphinx):
+def test_sphinx(session: nox.Session, sphinx: int) -> None:
"""Run the test suite with a specific version of Sphinx."""
if _should_install(session):
session.install("-e", ".[test]")
@@ -89,44 +101,54 @@ def test_sphinx(session, sphinx):
@nox.session()
-def translate(session):
- """Translation commands. Available commands after `--` : extract, update, compile"""
- session.install("Babel")
- if "extract" in session.posargs:
- session.run(
- *split(
- "pybabel extract . -F babel.cfg -o src/pydata_sphinx_theme/locale/sphinx.pot -k '_ __ l_ lazy_gettext'"
- )
- )
- elif "update" in session.posargs:
- session.run(
- *split(
- "pybabel update -i src/pydata_sphinx_theme/locale/sphinx.pot -d src/pydata_sphinx_theme/locale -D sphinx"
- )
- )
- elif "compile" in session.posargs:
- _compile_translations(session)
- elif "init" in session.posargs:
- language = session.posargs[-1]
- session.run(
- *split(
- f"pybabel init -i src/pydata_sphinx_theme/locale/sphinx.pot -d src/pydata_sphinx_theme/locale -D sphinx -l {language}"
- )
- )
- else:
+def translate(session: nox.Session) -> None:
+ """Translation commands. Available commands after `--` : extract, update, compile, init."""
+ # get the command from posargs, default to "update"
+ pybabel_cmd, found = ("update", False)
+ for c in ["extract", "update", "compile", "init"]:
+ if c in session.posargs:
+ pybabel_cmd, found = (c, True)
+
+ if found is False:
print(
"No translate command found. Use like: `nox -s translate -- COMMAND`."
- "\n\n Available commands: extract, update, compile, init"
+ "\ndefaulting to `update`"
+ "\nAvailable commands: extract, update, compile, init"
)
+ # get the language from parameters default to en.
+ # it can be deceiving but we don't have a table of accepted languages yet
+ lan = "en" if len(session.posargs) < 2 else session.posargs[-1]
-@nox.session(name="profile")
-def profile(session):
- """Generate a profile chart with py-spy. The chart will be placed at profile.svg."""
- import shutil as sh
- import tempfile
- from textwrap import dedent
+ # get the path to the differnet local related pieces
+ locale_dir = str(ROOT / "src" / "pydata_sphinx_theme" / "locale")
+ babel_cfg = str(ROOT / "babel.cfg")
+ pot_file = str(locale_dir / "sphinx.pot")
+
+ # install deps
+ session.install("Babel")
+
+ # build the command from the parameters
+ cmd = ["pybabel", pybabel_cmd]
+ if pybabel_cmd == "extract":
+ cmd += [ROOT, "-F", babel_cfg, "-o", pot_file, "-k", "_ __ l_ lazy_gettext"]
+
+ elif pybabel_cmd == "update":
+ cmd += ["-i", pot_file, "-d", locale_dir, "-D", "sphinx"]
+
+ elif pybabel_cmd == "compile":
+ cmd += ["-d", locale_dir, "-D", "sphinx"]
+
+ elif pybabel_cmd == "init":
+ cmd += ["-i", pot_file, "-d", locale_dir, "-D", "sphinx", "-l", lan]
+
+ session.run(cmd)
+
+
+@nox.session()
+def profile(session: nox.Session) -> None:
+ """Generate a profile chart with py-spy. The chart will be placed at profile.svg."""
if _should_install(session):
session.install("-e", ".[test]")
session.install("py-spy")
@@ -167,6 +189,6 @@ def profile(session):
# Profile the build
print(f"Profiling build with {n_extra_pages} pages with py-spy...")
session.run(
- *f"py-spy record -o {output} -- sphinx-build {path_tmp} {path_tmp_out}".split() # noqa
+ *f"py-spy record -o {output} -- sphinx-build {path_tmp} {path_tmp_out}".split()
)
print(f"py-spy profiler output at this file: {output}")
diff --git a/pyproject.toml b/pyproject.toml
index 8f1603177..423c57295 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,7 +27,8 @@ dependencies = [
"packaging",
"Babel",
"pygments>=2.7",
- "accessible-pygments"
+ "accessible-pygments",
+ "typing-extensions"
]
license = { file = "LICENSE" }
@@ -95,13 +96,20 @@ dev = [
[tool.doc8]
ignore = ["D001"] # we follow a 1 line = 1 paragraph style
-[tool.flake8]
-# E203: space before ":" | needed for how black formats slicing
-# E501: line too long | Black take care of it
-# W503: line break before binary operator | Black take care of it
-# W605: invalid escape sequence | we escape specific characters for sphinx
-ignore = ["E203", "E501", "W503", "W605"]
-exclude = ["setup.py", "docs/conf.py", "node_modules", "docs", "build", "dist"]
+[tool.ruff]
+ignore-init-module-imports = true
+fix = true
+select = ["E", "F", "W", "I", "D", "RUF"]
+ignore = [
+ "E501", # line too long | Black take care of it
+ "D107", # Missing docstring in `__init__` | set the docstring in the class
+]
+
+[tool.ruff.flake8-quotes]
+docstring-quotes = "double"
+
+[tool.ruff.pydocstyle]
+convention = "google"
[tool.djlint]
profile = "jinja"
diff --git a/src/pydata_sphinx_theme/__init__.py b/src/pydata_sphinx_theme/__init__.py
index 82a23fbd7..fd403b045 100644
--- a/src/pydata_sphinx_theme/__init__.py
+++ b/src/pydata_sphinx_theme/__init__.py
@@ -1,66 +1,31 @@
-"""
-Bootstrap-based sphinx theme from the PyData community
-"""
+"""Bootstrap-based sphinx theme from the PyData community."""
+
+import json
import os
from pathlib import Path
-from functools import lru_cache
-import json
-from urllib.parse import urlparse, urlunparse
-import types
+from typing import Dict
+from urllib.parse import urlparse
-import jinja2
-from bs4 import BeautifulSoup as bs
-from docutils import nodes
-from sphinx import addnodes
-from sphinx.application import Sphinx
-from sphinx.environment.adapters.toctree import TocTree
-from sphinx.addnodes import toctree as toctree_node
-from sphinx.transforms.post_transforms import SphinxPostTransform
-from sphinx.util.nodes import NodeMatcher
-from sphinx.errors import ExtensionError
-from sphinx.util import logging, isurl
-from sphinx.util.fileutil import copy_asset_file
-from pygments.formatters import HtmlFormatter
-from pygments.styles import get_all_styles
import requests
from requests.exceptions import ConnectionError, HTTPError, RetryError
+from sphinx.application import Sphinx
+from sphinx.errors import ExtensionError
+from sphinx.util import logging
-from .translator import BootstrapHTML5TranslatorMixin
+from . import edit_this_page, logo, pygment, short_link, toctree, translator, utils
__version__ = "0.13.3dev0"
logger = logging.getLogger(__name__)
-def _get_theme_options_dict(app):
- """Get the Sphinx theme options dictionary (or fallback to an empty dict).
-
- The "top-level" mapping (the one we should usually check first, and modify
- if desired) is ``app.builder.theme_options``. It is created by Sphinx as a
- copy of ``app.config.html_theme_options`` (containing user-configs from
- their ``conf.py``); sometimes that copy never occurs though which is why we
- check both.
- """
- if hasattr(app.builder, "theme_options"):
- return app.builder.theme_options
- elif hasattr(app.config, "html_theme_options"):
- return app.config.html_theme_options
- else:
- return {}
-
-
-def _config_provided_by_user(app, key):
- """Check if the user has manually provided the config."""
- return any(key in ii for ii in [app.config.overrides, app.config._raw_config])
-
-
def update_config(app):
"""Update config with new default values and handle deprecated keys."""
# By the time `builder-inited` happens, `app.builder.theme_options` already exists.
# At this point, modifying app.config.html_theme_options will NOT update the
# page's HTML context (e.g. in jinja, `theme_keyword`).
# To do this, you must manually modify `app.builder.theme_options`.
- theme_options = _get_theme_options_dict(app)
+ theme_options = utils.get_theme_options_dict(app)
# TODO: deprecation; remove after 0.14 release
if theme_options.get("logo_text"):
@@ -103,7 +68,7 @@ def update_config(app):
)
# Set the anchor link default to be # if the user hasn't provided their own
- if not _config_provided_by_user(app, "html_permalinks_icon"):
+ if not utils.config_provided_by_user(app, "html_permalinks_icon"):
app.config.html_permalinks_icon = "#"
# Raise a warning for a deprecated theme switcher config
@@ -190,9 +155,8 @@ def update_config(app):
app.add_js_file(None, body=gid_script)
# Update ABlog configuration default if present
- if "ablog" in app.config.extensions and not _config_provided_by_user(
- app, "fontawesome_included"
- ):
+ fa_provided = utils.config_provided_by_user(app, "fontawesome_included")
+ if "ablog" in app.config.extensions and not fa_provided:
app.config.fontawesome_included = True
# Handle icon link shortcuts
@@ -229,7 +193,9 @@ def update_config(app):
theme_options["logo"] = theme_logo
-def update_and_remove_templates(app, pagename, templatename, context, doctree):
+def update_and_remove_templates(
+ app: Sphinx, pagename: str, templatename: str, context, doctree
+) -> None:
"""Update template names and assets for page build."""
# Allow for more flexibility in template names
template_sections = [
@@ -314,903 +280,23 @@ def _remove_empty_templates(tname):
context["theme_version"] = __version__
-def add_inline_math(node):
- """Render a node with HTML tags that activate MathJax processing.
- This is meant for use with rendering section titles with math in them, because
- math outputs are ignored by pydata-sphinx-theme's header.
-
- related to the behaviour of a normal math node from:
- https://github.com/sphinx-doc/sphinx/blob/master/sphinx/ext/mathjax.py#L28
- """
-
- return (
- '' rf"\({node.astext()}\)" ""
- )
-
-
-def add_toctree_functions(app, pagename, templatename, context, doctree):
- """Add functions so Jinja templates can add toctree objects."""
-
- @lru_cache(maxsize=None)
- def generate_header_nav_html(n_links_before_dropdown=5):
- """
- Generate top-level links that are meant for the header navigation.
- We use this function instead of the TocTree-based one used for the
- sidebar because this one is much faster for generating the links and
- we don't need the complexity of the full Sphinx TocTree.
-
- This includes two kinds of links:
-
- - Links to pages described listed in the root_doc TocTrees
- - External links defined in theme configuration
-
- Additionally it will create a dropdown list for several links after
- a cutoff.
-
- Parameters
- ----------
- n_links_before_dropdown : int (default: 5)
- The number of links to show before nesting the remaining links in
- a Dropdown element.
- """
-
- try:
- n_links_before_dropdown = int(n_links_before_dropdown)
- except Exception:
- raise ValueError(
- f"n_links_before_dropdown is not an int: {n_links_before_dropdown}"
- )
- toctree = TocTree(app.env)
-
- # Find the active header navigation item so we decide whether to highlight
- # Will be empty if there is no active page (root_doc, or genindex etc)
- active_header_page = toctree.get_toctree_ancestors(pagename)
- if active_header_page:
- # The final list item will be the top-most ancestor
- active_header_page = active_header_page[-1]
-
- # Find the root document because it lists our top-level toctree pages
- root = app.env.tocs[app.config.root_doc]
-
- # Iterate through each toctree node in the root document
- # Grab the toctree pages and find the relative link + title.
- links_html = []
- # TODO: just use "findall" once docutils min version >=0.18.1
- meth = "findall" if hasattr(root, "findall") else "traverse"
- for toc in getattr(root, meth)(toctree_node):
- for title, page in toc.attributes["entries"]:
- # if the page is using "self" use the correct link
- page = toc.attributes["parent"] if page == "self" else page
-
- # If this is the active ancestor page, add a class so we highlight it
- current = " current active" if page == active_header_page else ""
-
- # sanitize page title for use in the html output if needed
- if title is None:
- title = ""
- for node in app.env.titles[page].children:
- if isinstance(node, nodes.math):
- title += add_inline_math(node)
- else:
- title += node.astext()
-
- # set up the status of the link and the path
- # if the path is relative then we use the context for the path
- # resolution and the internal class.
- # If it's an absolute one then we use the external class and
- # the complete url.
- is_absolute = bool(urlparse(page).netloc)
- link_status = "external" if is_absolute else "internal"
- link_href = page if is_absolute else context["pathto"](page)
-
- # create the html output
- links_html.append(
- f"""
-
- """
- )
-
- # Add external links defined in configuration as sibling list items
- for external_link in context["theme_external_links"]:
- links_html.append(
- f"""
-
- """
- )
-
- # The first links will always be visible
- links_solo = links_html[:n_links_before_dropdown]
- out = "\n".join(links_solo)
-
- # Wrap the final few header items in a "more" dropdown
- links_dropdown = links_html[n_links_before_dropdown:]
- if links_dropdown:
- links_dropdown_html = "\n".join(links_dropdown)
- out += f"""
-
-
-
- {links_dropdown_html}
-
-
- """ # noqa
-
- return out
-
- # Cache this function because it is expensive to run, and because Sphinx
- # somehow runs this twice in some circumstances in unpredictable ways.
- @lru_cache(maxsize=None)
- def generate_toctree_html(kind, startdepth=1, show_nav_level=1, **kwargs):
- """
- Return the navigation link structure in HTML. This is similar to Sphinx's
- own default TocTree generation, but it is modified to generate TocTrees
- for *second*-level pages and below (not supported by default in Sphinx).
- This is used for our sidebar, which starts at the second-level page.
-
- It also modifies the generated TocTree slightly for Bootstrap classes
- and structure (via BeautifulSoup).
-
- Arguments are passed to Sphinx "toctree" function (context["toctree"] below).
-
- ref: https://www.sphinx-doc.org/en/master/templating.html#toctree
-
- Parameters
- ----------
- kind : "sidebar" or "raw"
- Whether to generate HTML meant for sidebar navigation ("sidebar")
- or to return the raw BeautifulSoup object ("raw").
- startdepth : int
- The level of the toctree at which to start. By default, for
- the navbar uses the normal toctree (`startdepth=0`), and for
- the sidebar starts from the second level (`startdepth=1`).
- show_nav_level : int
- The level of the navigation bar to toggle as visible on page load.
- By default, this level is 1, and only top-level pages are shown,
- with drop-boxes to reveal children. Increasing `show_nav_level`
- will show child levels as well.
-
- kwargs: passed to the Sphinx `toctree` template function.
-
- Returns
- -------
- HTML string (if kind == "sidebar") OR
- BeautifulSoup object (if kind == "raw")
- """
- if startdepth == 0:
- toc_sphinx = context["toctree"](**kwargs)
- else:
- # select the "active" subset of the navigation tree for the sidebar
- toc_sphinx = index_toctree(app, pagename, startdepth, **kwargs)
-
- soup = bs(toc_sphinx, "html.parser")
-
- # pair "current" with "active" since that's what we use w/ bootstrap
- for li in soup("li", {"class": "current"}):
- li["class"].append("active")
-
- # Remove sidebar links to sub-headers on the page
- for li in soup.select("li"):
- # Remove
- if li.find("a"):
- href = li.find("a")["href"]
- if "#" in href and href != "#":
- li.decompose()
-
- if kind == "sidebar":
- # Add bootstrap classes for first `ul` items
- for ul in soup("ul", recursive=False):
- ul.attrs["class"] = ul.attrs.get("class", []) + ["nav", "bd-sidenav"]
-
- # Add collapse boxes for parts/captions.
- # Wraps the TOC part in an extra
to behave like chapters with toggles
- # show_nav_level: 0 means make parts collapsible.
- if show_nav_level == 0:
- partcaptions = soup.find_all("p", attrs={"class": "caption"})
- if len(partcaptions):
- new_soup = bs("
", "html.parser")
- for caption in partcaptions:
- # Assume that the next
element is the TOC list
- # for this part
- for sibling in caption.next_siblings:
- if sibling.name == "ul":
- toclist = sibling
- break
- li = soup.new_tag("li", attrs={"class": "toctree-l0"})
- li.extend([caption, toclist])
- new_soup.ul.append(li)
- soup = new_soup
-
- # Add icons and labels for collapsible nested sections
- _add_collapse_checkboxes(soup)
-
- # Open the sidebar navigation to the proper depth
- for ii in range(int(show_nav_level)):
- for checkbox in soup.select(
- f"li.toctree-l{ii} > input.toctree-checkbox"
- ):
- checkbox.attrs["checked"] = None
-
- return soup
-
- @lru_cache(maxsize=None)
- def generate_toc_html(kind="html"):
- """Return the within-page TOC links in HTML."""
-
- if "toc" not in context:
- return ""
-
- soup = bs(context["toc"], "html.parser")
-
- # Add toc-hN + visible classes
- def add_header_level_recursive(ul, level):
- if ul is None:
- return
- if level <= (context["theme_show_toc_level"] + 1):
- ul["class"] = ul.get("class", []) + ["visible"]
- for li in ul("li", recursive=False):
- li["class"] = li.get("class", []) + [f"toc-h{level}"]
- add_header_level_recursive(li.find("ul", recursive=False), level + 1)
-
- add_header_level_recursive(soup.find("ul"), 1)
-
- # Add in CSS classes for bootstrap
- for ul in soup("ul"):
- ul["class"] = ul.get("class", []) + ["nav", "section-nav", "flex-column"]
-
- for li in soup("li"):
- li["class"] = li.get("class", []) + ["nav-item", "toc-entry"]
- if li.find("a"):
- a = li.find("a")
- a["class"] = a.get("class", []) + ["nav-link"]
-
- # If we only have one h1 header, assume it's a title
- h1_headers = soup.select(".toc-h1")
- if len(h1_headers) == 1:
- title = h1_headers[0]
- # If we have no sub-headers of a title then we won't have a TOC
- if not title.select(".toc-h2"):
- out = ""
- else:
- out = title.find("ul")
- # Else treat the h1 headers as sections
- else:
- out = soup
-
- # Return the toctree object
- if kind == "html":
- return out
- else:
- return soup
-
- def navbar_align_class():
- """Return the class that aligns the navbar based on config."""
- align = context.get("theme_navbar_align", "content")
- align_options = {
- "content": ("col-lg-9", "me-auto"),
- "left": ("", "me-auto"),
- "right": ("", "ms-auto"),
- }
- if align not in align_options:
- raise ValueError(
- "Theme option navbar_align must be one of"
- f"{align_options.keys()}, got: {align}"
- )
- return align_options[align]
-
- context["generate_header_nav_html"] = generate_header_nav_html
- context["generate_toctree_html"] = generate_toctree_html
- context["generate_toc_html"] = generate_toc_html
- context["navbar_align_class"] = navbar_align_class
-
-
-def _add_collapse_checkboxes(soup):
- """Add checkboxes to collapse children in a toctree."""
- # based on https://github.com/pradyunsg/furo
-
- toctree_checkbox_count = 0
-
- for element in soup.find_all("li", recursive=True):
- # We check all "li" elements, to add a "current-page" to the correct li.
- classes = element.get("class", [])
-
- # expanding the parent part explicitly, if present
- if "current" in classes:
- parentli = element.find_parent("li", class_="toctree-l0")
- if parentli:
- parentli.select("p.caption ~ input")[0].attrs["checked"] = ""
-
- # Nothing more to do, unless this has "children"
- if not element.find("ul"):
- continue
-
- # Add a class to indicate that this has children.
- element["class"] = classes + ["has-children"]
-
- # We're gonna add a checkbox.
- toctree_checkbox_count += 1
- checkbox_name = f"toctree-checkbox-{toctree_checkbox_count}"
-
- # Add the "label" for the checkbox which will get filled.
- if soup.new_tag is None:
- continue
-
- label = soup.new_tag(
- "label", attrs={"for": checkbox_name, "class": "toctree-toggle"}
- )
- label.append(soup.new_tag("i", attrs={"class": "fa-solid fa-chevron-down"}))
- if "toctree-l0" in classes:
- # making label cover the whole caption text with css
- label["class"] = "label-parts"
- element.insert(1, label)
-
- # Add the checkbox that's used to store expanded/collapsed state.
- checkbox = soup.new_tag(
- "input",
- attrs={
- "type": "checkbox",
- "class": ["toctree-checkbox"],
- "id": checkbox_name,
- "name": checkbox_name,
- },
- )
-
- # if this has a "current" class, be expanded by default
- # (by checking the checkbox)
- if "current" in classes:
- checkbox.attrs["checked"] = ""
-
- element.insert(1, checkbox)
-
-
-def _get_local_toctree_for(
- self: TocTree, indexname: str, docname: str, builder, collapse: bool, **kwargs
-):
- """Return the "local" TOC nodetree (relative to `indexname`)."""
- # this is a copy of `TocTree.get_toctree_for`, but where the sphinx version
- # always uses the "root" doctree:
- # doctree = self.env.get_doctree(self.env.config.root_doc)
- # we here use the `indexname` additional argument to be able to use a subset
- # of the doctree (e.g. starting at a second level for the sidebar):
- # doctree = app.env.tocs[indexname].deepcopy()
-
- doctree = self.env.tocs[indexname].deepcopy()
-
- toctrees = []
- if "includehidden" not in kwargs:
- kwargs["includehidden"] = True
- if "maxdepth" not in kwargs or not kwargs["maxdepth"]:
- kwargs["maxdepth"] = 0
- else:
- kwargs["maxdepth"] = int(kwargs["maxdepth"])
- kwargs["collapse"] = collapse
-
- # FIX: Can just use "findall" once docutils 0.18+ is required
- meth = "findall" if hasattr(doctree, "findall") else "traverse"
- for toctreenode in getattr(doctree, meth)(addnodes.toctree):
- toctree = self.resolve(docname, builder, toctreenode, prune=True, **kwargs)
- if toctree:
- toctrees.append(toctree)
- if not toctrees:
- return None
- result = toctrees[0]
- for toctree in toctrees[1:]:
- result.extend(toctree.children)
- return result
-
-
-def index_toctree(app, pagename: str, startdepth: int, collapse: bool = True, **kwargs):
- """
- Returns the "local" (starting at `startdepth`) TOC tree containing the
- current page, rendered as HTML bullet lists.
-
- This is the equivalent of `context["toctree"](**kwargs)` in sphinx
- templating, but using the startdepth-local instead of global TOC tree.
- """
- # this is a variant of the function stored in `context["toctree"]`, which is
- # defined as `lambda **kwargs: self._get_local_toctree(pagename, **kwargs)`
- # with `self` being the HMTLBuilder and the `_get_local_toctree` basically
- # returning:
- # return self.render_partial(TocTree(self.env).get_toctree_for(
- # pagename, self, collapse, **kwargs))['fragment']
-
- if "includehidden" not in kwargs:
- kwargs["includehidden"] = False
- if kwargs.get("maxdepth") == "":
- kwargs.pop("maxdepth")
-
- toctree = TocTree(app.env)
- ancestors = toctree.get_toctree_ancestors(pagename)
- try:
- indexname = ancestors[-startdepth]
- except IndexError:
- # eg for index.rst, but also special pages such as genindex, py-modindex, search
- # those pages don't have a "current" element in the toctree, so we can
- # directly return an empty string instead of using the default sphinx
- # toctree.get_toctree_for(pagename, app.builder, collapse, **kwargs)
- return ""
-
- toctree_element = _get_local_toctree_for(
- toctree, indexname, pagename, app.builder, collapse, **kwargs
- )
- return app.builder.render_partial(toctree_element)["fragment"]
-
-
-def soup_to_python(soup, only_pages=False):
- """
- Convert the toctree html structure to python objects which can be used in Jinja.
-
- Parameters
- ----------
- soup : BeautifulSoup object for the toctree
- only_pages : bool
- Only include items for full pages in the output dictionary. Exclude
- anchor links (TOC items with a URL that starts with #)
-
- Returns
- -------
- nav : list of dicts
- The toctree, converted into a dictionary with key/values that work
- within Jinja.
- """
- # toctree has this structure (caption only for toctree, not toc)
- #
- # ...
-
- def extract_level_recursive(ul, navs_list):
- for li in ul.find_all("li", recursive=False):
- ref = li.a
- url = ref["href"]
- title = "".join(map(str, ref.contents))
- active = "current" in li.get("class", [])
-
- # If we've got an anchor link, skip it if we wish
- if only_pages and "#" in url and url != "#":
- continue
-
- # Converting the docutils attributes into jinja-friendly objects
- nav = {}
- nav["title"] = title
- nav["url"] = url
- nav["active"] = active
-
- navs_list.append(nav)
-
- # Recursively convert children as well
- nav["children"] = []
- ul = li.find("ul", recursive=False)
- if ul:
- extract_level_recursive(ul, nav["children"])
-
- navs = []
- for ul in soup.find_all("ul", recursive=False):
- extract_level_recursive(ul, navs)
-
- return navs
-
-
-# -----------------------------------------------------------------------------
-
-
-def setup_edit_url(app, pagename, templatename, context, doctree):
- """Add a function that jinja can access for returning the edit URL of a page."""
-
- def get_edit_provider_and_url():
- """Return a provider name and a URL for an "edit this page" link."""
- file_name = f"{pagename}{context['page_source_suffix']}"
-
- # Make sure that doc_path has a path separator only if it exists (to avoid //)
- doc_path = context.get("doc_path", "")
- if doc_path and not doc_path.endswith("/"):
- doc_path = f"{doc_path}/"
-
- default_provider_urls = {
- "bitbucket_url": "https://bitbucket.org",
- "github_url": "https://github.com",
- "gitlab_url": "https://gitlab.com",
- }
-
- edit_attrs = {}
-
- # ensure custom URL is checked first, if given
- url_template = context.get("edit_page_url_template")
-
- if url_template is not None:
- if "file_name" not in url_template:
- raise ExtensionError(
- "Missing required value for `use_edit_page_button`. "
- "Ensure `file_name` appears in `edit_page_url_template`: "
- f"{url_template}"
- )
- provider_name = context.get("edit_page_provider_name")
- edit_attrs[("edit_page_url_template",)] = (provider_name, url_template)
-
- edit_attrs.update(
- {
- ("bitbucket_user", "bitbucket_repo", "bitbucket_version"): (
- "Bitbucket",
- "{{ bitbucket_url }}/{{ bitbucket_user }}/{{ bitbucket_repo }}"
- "/src/{{ bitbucket_version }}"
- "/{{ doc_path }}{{ file_name }}?mode=edit",
- ),
- ("github_user", "github_repo", "github_version"): (
- "GitHub",
- "{{ github_url }}/{{ github_user }}/{{ github_repo }}"
- "/edit/{{ github_version }}/{{ doc_path }}{{ file_name }}",
- ),
- ("gitlab_user", "gitlab_repo", "gitlab_version"): (
- "GitLab",
- "{{ gitlab_url }}/{{ gitlab_user }}/{{ gitlab_repo }}"
- "/-/edit/{{ gitlab_version }}/{{ doc_path }}{{ file_name }}",
- ),
- }
- )
-
- doc_context = dict(default_provider_urls)
- doc_context.update(context)
- doc_context.update(doc_path=doc_path, file_name=file_name)
-
- for attrs, (provider, url_template) in edit_attrs.items():
- if all(doc_context.get(attr) not in [None, "None"] for attr in attrs):
- return provider, jinja2.Template(url_template).render(**doc_context)
-
- raise ExtensionError(
- "Missing required value for `use_edit_page_button`. "
- "Ensure one set of the following in your `html_context` "
- f"configuration: {sorted(edit_attrs.keys())}"
- )
-
- context["get_edit_provider_and_url"] = get_edit_provider_and_url
-
- # Ensure that the max TOC level is an integer
- context["theme_show_toc_level"] = int(context.get("theme_show_toc_level", 1))
-
-
-# ------------------------------------------------------------------------------
-# handle pygment css
-# ------------------------------------------------------------------------------
-
-# inspired by the Furo theme
-# https://github.com/pradyunsg/furo/blob/main/src/furo/__init__.py
-
-
-def _get_styles(formatter, prefix):
- """
- Get styles out of a formatter, where everything has the correct prefix.
- """
-
- for line in formatter.get_linenos_style_defs():
- yield f"{prefix} {line}"
- yield from formatter.get_background_style_defs(prefix)
- yield from formatter.get_token_style_defs(prefix)
-
-
-def get_pygments_stylesheet(light_style, dark_style):
- """
- Generate the theme-specific pygments.css.
- There is no way to tell Sphinx how the theme handles modes
- """
- light_formatter = HtmlFormatter(style=light_style)
- dark_formatter = HtmlFormatter(style=dark_style)
-
- lines = []
-
- light_prefix = 'html[data-theme="light"] .highlight'
- lines.extend(_get_styles(light_formatter, prefix=light_prefix))
-
- dark_prefix = 'html[data-theme="dark"] .highlight'
- lines.extend(_get_styles(dark_formatter, prefix=dark_prefix))
-
- return "\n".join(lines)
-
-
-def _overwrite_pygments_css(app, exception=None):
- """
- Overwrite pygments.css to allow dynamic light/dark switching.
-
- Sphinx natively supports config variables `pygments_style` and
- `pygments_dark_style`. However, quoting from
- www.sphinx-doc.org/en/master/development/theming.html#creating-themes
-
- The pygments_dark_style setting [...is used] when the CSS media query
- (prefers-color-scheme: dark) evaluates to true.
-
- This does not allow for dynamic switching by the user, so at build time we
- overwrite the pygment.css file so that it embeds 2 versions:
-
- - the light theme prefixed with "[data-theme="light"]"
- - the dark theme prefixed with "[data-theme="dark"]"
-
- Fallbacks are defined in this function in case the user-requested (or our
- theme-specified) pygments theme is not available.
- """
- if exception is not None:
- return
-
- assert app.builder
-
- pygments_styles = list(get_all_styles())
- fallbacks = dict(light="tango", dark="monokai")
-
- for light_or_dark, fallback in fallbacks.items():
- # make sure our fallbacks work; if not fall(further)back to "default"
- if fallback not in pygments_styles:
- fallback = pygments_styles[0] # should resolve to "default"
-
- # see if user specified a light/dark pygments theme:
- style_key = f"pygment_{light_or_dark}_style"
- style_name = _get_theme_options_dict(app).get(style_key, None)
- # if not, use the one we set in `theme.conf`:
- if style_name is None and hasattr(app.builder, "theme"):
- style_name = app.builder.theme.get_options()[style_key]
- # make sure we can load the style
- if style_name not in pygments_styles:
- # only warn if user asked for a highlight theme that we can't find
- if style_name is not None:
- logger.warning(
- f"Highlighting style {style_name} not found by pygments, "
- f"falling back to {fallback}."
- )
- style_name = fallback
- # assign to the appropriate variable
- if light_or_dark == "light":
- light_theme = style_name
- else:
- dark_theme = style_name
- # re-write pygments.css
- pygment_css = Path(app.builder.outdir) / "_static" / "pygments.css"
- with pygment_css.open("w") as f:
- f.write(get_pygments_stylesheet(light_theme, dark_theme))
-
-
-# ------------------------------------------------------------------------------
-# customize rendering of the links
-# ------------------------------------------------------------------------------
-
-
-def _traverse_or_findall(node, condition, **kwargs):
- """Triage node.traverse (docutils <0.18.1) vs node.findall.
- TODO: This check can be removed when the minimum supported docutils version
- for numpydoc is docutils>=0.18.1
- """
- return (
- node.findall(condition, **kwargs)
- if hasattr(node, "findall")
- else node.traverse(condition, **kwargs)
- )
-
-
-class ShortenLinkTransform(SphinxPostTransform):
- """
- Shorten link when they are coming from github or gitlab and add an extra class to the tag
- for further styling.
- Before::
-
- https://github.com/2i2c-org/infrastructure/issues/1329
-
- After::
-
- 2i2c-org/infrastructure#1329
-
- """ # noqa
-
- default_priority = 400
- formats = ("html",)
- supported_platform = {"github.com": "github", "gitlab.com": "gitlab"}
- platform = None
-
- def run(self, **kwargs):
- matcher = NodeMatcher(nodes.reference)
- # TODO: just use "findall" once docutils min version >=0.18.1
- for node in _traverse_or_findall(self.document, matcher):
- uri = node.attributes.get("refuri")
- text = next(iter(node.children), None)
- # only act if the uri and text are the same
- # if not the user has already customized the display of the link
- if uri is not None and text is not None and text == uri:
- uri = urlparse(uri)
- # only do something if the platform is identified
- self.platform = self.supported_platform.get(uri.netloc)
- if self.platform is not None:
- node.attributes["classes"].append(self.platform)
- node.children[0] = nodes.Text(self.parse_url(uri))
-
- def parse_url(self, uri):
- """
- parse the content of the url with respect to the selected platform
- """
- path = uri.path
-
- if path == "":
- # plain url passed, return platform only
- return self.platform
-
- # if the path is not empty it contains a leading "/", which we don't want to
- # include in the parsed content
- path = path.lstrip("/")
-
- # check the platform name and read the information accordingly
- # as "/#"
- # or "//…//#"
- if self.platform == "github":
- # split the url content
- parts = path.split("/")
-
- if parts[0] == "orgs" and "/projects" in path:
- # We have a projects board link
- # ref: `orgs/{org}/projects/{project-id}`
- text = f"{parts[1]}/projects#{parts[3]}"
- else:
- # We have an issues, PRs, or repository link
- if len(parts) > 0:
- text = parts[0] # organisation
- if len(parts) > 1:
- text += f"/{parts[1]}" # repository
- if len(parts) > 2:
- if parts[2] in ["issues", "pull", "discussions"]:
- text += f"#{parts[-1]}" # element number
-
- elif self.platform == "gitlab":
- # cp. https://docs.gitlab.com/ee/user/markdown.html#gitlab-specific-references
- if "/-/" in path and any(
- map(uri.path.__contains__, ["issues", "merge_requests"])
- ):
- group_and_subgroups, parts, *_ = path.split("/-/")
- parts = parts.split("/")
- url_type, element_number, *_ = parts
- if url_type == "issues":
- text = f"{group_and_subgroups}#{element_number}"
- elif url_type == "merge_requests":
- text = f"{group_and_subgroups}!{element_number}"
- else:
- # display the whole uri (after "gitlab.com/") including parameters
- # for example "///"
- text = uri._replace(netloc="", scheme="") # remove platform
- text = urlunparse(text)[1:] # combine to string and strip leading "/"
-
- return text
-
-
-def setup_translators(app):
- """
- Add bootstrap HTML functionality if we are using an HTML translator.
-
- This re-uses the pre-existing Sphinx translator and adds extra functionality defined
- in ``BootstrapHTML5TranslatorMixin``. This way we can retain the original translator's
- behavior and configuration, and _only_ add the extra bootstrap rules.
- If we don't detect an HTML-based translator, then we do nothing.
- """
- if not app.registry.translators.items():
- translator = types.new_class(
- "BootstrapHTML5Translator",
- (
- BootstrapHTML5TranslatorMixin,
- app.builder.default_translator_class,
- ),
- {},
- )
- app.set_translator(app.builder.name, translator, override=True)
- else:
- for name, klass in app.registry.translators.items():
- if app.builder.format != "html":
- # Skip translators that are not HTML
- continue
-
- translator = types.new_class(
- "BootstrapHTML5Translator",
- (
- BootstrapHTML5TranslatorMixin,
- klass,
- ),
- {},
- )
- app.set_translator(name, translator, override=True)
-
-
-# ------------------------------------------------------------------------------
-# customize events for logo management
-# we use one event to copy over custom logo images to _static
-# and another even to link them in the html context
-# ------------------------------------------------------------------------------
-
-
-def setup_logo_path(
- app: Sphinx, pagename: str, templatename: str, context: dict, doctree: nodes.Node
-) -> None:
- """Set up relative paths to logos in our HTML templates.
-
- In Sphinx, the context["logo"] is a path to the `html_logo` image now in the output
- `_static` folder.
-
- If logo["image_light"] and logo["image_dark"] are given, we must modify them to
- follow the same pattern. They have already been copied to the output folder
- in the `update_config` event.
- """
-
- # get information from the context "logo_url" for sphinx>=6, "logo" sphinx<6
- pathto = context.get("pathto")
- logo = context.get("logo_url") or context.get("logo")
- theme_logo = context.get("theme_logo", {})
-
- # Define the final path to logo images in the HTML context
- theme_logo["image_relative"] = {}
- for kind in ["light", "dark"]:
- image_kind_logo = theme_logo.get(f"image_{kind}")
-
- # If it's a URL the "relative" path is just the URL
- # else we need to calculate the relative path to a local file
- if image_kind_logo:
- if not isurl(image_kind_logo):
- image_kind_name = Path(image_kind_logo).name
- image_kind_logo = pathto(f"_static/{image_kind_name}", resource=True)
- theme_logo["image_relative"][kind] = image_kind_logo
-
- # If there's no custom logo for this kind, just use `html_logo`
- # If `logo` is also None, then do not add this key to context.
- elif isinstance(logo, str) and len(logo) > 0:
- theme_logo["image_relative"][kind] = logo
-
- # Update our context logo variables with the new image paths
- context["theme_logo"] = theme_logo
-
-
-def copy_logo_images(app: Sphinx, exception=None) -> None:
- """
- If logo image paths are given, copy them to the `_static` folder
- Then we can link to them directly in an html_page_context event
- """
- logo = _get_theme_options_dict(app).get("logo", {})
- staticdir = Path(app.builder.outdir) / "_static"
- for kind in ["light", "dark"]:
- path_image = logo.get(f"image_{kind}")
- if not path_image or isurl(path_image):
- continue
- if (staticdir / Path(path_image).name).exists():
- # file already exists in static dir e.g. because a theme has
- # bundled the logo and installed it there
- continue
- if not (Path(app.srcdir) / path_image).exists():
- logger.warning(f"Path to {kind} image logo does not exist: {path_image}")
- # Ensure templates cannot be passed for logo path to avoid security vulnerability
- if path_image.lower().endswith("_t"):
- raise ExtensionError(
- f"The {kind} logo path '{path_image}' looks like a Sphinx template; "
- "please provide a static logo image."
- )
- copy_asset_file(path_image, staticdir)
-
-
-# -----------------------------------------------------------------------------
-
-
-def setup(app):
+def setup(app: Sphinx) -> Dict[str, str]:
+ """Setup the Sphinx application."""
here = Path(__file__).parent.resolve()
theme_path = here / "theme" / "pydata_sphinx_theme"
app.add_html_theme("pydata_sphinx_theme", str(theme_path))
- app.add_post_transform(ShortenLinkTransform)
+ app.add_post_transform(short_link.ShortenLinkTransform)
- app.connect("builder-inited", setup_translators)
+ app.connect("builder-inited", translator.setup_translators)
app.connect("builder-inited", update_config)
- app.connect("html-page-context", setup_edit_url)
- app.connect("html-page-context", add_toctree_functions)
+ app.connect("html-page-context", edit_this_page.setup_edit_url)
+ app.connect("html-page-context", toctree.add_toctree_functions)
app.connect("html-page-context", update_and_remove_templates)
- app.connect("html-page-context", setup_logo_path)
- app.connect("build-finished", _overwrite_pygments_css)
- app.connect("build-finished", copy_logo_images)
+ app.connect("html-page-context", logo.setup_logo_path)
+ app.connect("build-finished", pygment.overwrite_pygments_css)
+ app.connect("build-finished", logo.copy_logo_images)
# https://www.sphinx-doc.org/en/master/extdev/i18n.html#extension-internationalization-i18n-and-localization-l10n-using-i18n-api
app.add_message_catalog("sphinx", here / "locale")
diff --git a/src/pydata_sphinx_theme/edit_this_page.py b/src/pydata_sphinx_theme/edit_this_page.py
new file mode 100644
index 000000000..7c97c86dc
--- /dev/null
+++ b/src/pydata_sphinx_theme/edit_this_page.py
@@ -0,0 +1,81 @@
+"""Create an "edit this page" url compatible with bitbucket, gitlab and github."""
+
+import jinja2
+from sphinx.application import Sphinx
+from sphinx.errors import ExtensionError
+
+
+def setup_edit_url(
+ app: Sphinx, pagename: str, templatename: str, context, doctree
+) -> None:
+ """Add a function that jinja can access for returning the edit URL of a page."""
+
+ def get_edit_provider_and_url() -> None:
+ """Return a provider name and a URL for an "edit this page" link."""
+ file_name = f"{pagename}{context['page_source_suffix']}"
+
+ # Make sure that doc_path has a path separator only if it exists (to avoid //)
+ doc_path = context.get("doc_path", "")
+ if doc_path and not doc_path.endswith("/"):
+ doc_path = f"{doc_path}/"
+
+ default_provider_urls = {
+ "bitbucket_url": "https://bitbucket.org",
+ "github_url": "https://github.com",
+ "gitlab_url": "https://gitlab.com",
+ }
+
+ edit_attrs = {}
+
+ # ensure custom URL is checked first, if given
+ url_template = context.get("edit_page_url_template")
+
+ if url_template is not None:
+ if "file_name" not in url_template:
+ raise ExtensionError(
+ "Missing required value for `use_edit_page_button`. "
+ "Ensure `file_name` appears in `edit_page_url_template`: "
+ f"{url_template}"
+ )
+ provider_name = context.get("edit_page_provider_name")
+ edit_attrs[("edit_page_url_template",)] = (provider_name, url_template)
+
+ edit_attrs.update(
+ {
+ ("bitbucket_user", "bitbucket_repo", "bitbucket_version"): (
+ "Bitbucket",
+ "{{ bitbucket_url }}/{{ bitbucket_user }}/{{ bitbucket_repo }}"
+ "/src/{{ bitbucket_version }}"
+ "/{{ doc_path }}{{ file_name }}?mode=edit",
+ ),
+ ("github_user", "github_repo", "github_version"): (
+ "GitHub",
+ "{{ github_url }}/{{ github_user }}/{{ github_repo }}"
+ "/edit/{{ github_version }}/{{ doc_path }}{{ file_name }}",
+ ),
+ ("gitlab_user", "gitlab_repo", "gitlab_version"): (
+ "GitLab",
+ "{{ gitlab_url }}/{{ gitlab_user }}/{{ gitlab_repo }}"
+ "/-/edit/{{ gitlab_version }}/{{ doc_path }}{{ file_name }}",
+ ),
+ }
+ )
+
+ doc_context = dict(default_provider_urls)
+ doc_context.update(context)
+ doc_context.update(doc_path=doc_path, file_name=file_name)
+
+ for attrs, (provider, url_template) in edit_attrs.items():
+ if all(doc_context.get(attr) not in [None, "None"] for attr in attrs):
+ return provider, jinja2.Template(url_template).render(**doc_context)
+
+ raise ExtensionError(
+ "Missing required value for `use_edit_page_button`. "
+ "Ensure one set of the following in your `html_context` "
+ f"configuration: {sorted(edit_attrs.keys())}"
+ )
+
+ context["get_edit_provider_and_url"] = get_edit_provider_and_url
+
+ # Ensure that the max TOC level is an integer
+ context["theme_show_toc_level"] = int(context.get("theme_show_toc_level", 1))
diff --git a/src/pydata_sphinx_theme/logo.py b/src/pydata_sphinx_theme/logo.py
new file mode 100644
index 000000000..b93784a41
--- /dev/null
+++ b/src/pydata_sphinx_theme/logo.py
@@ -0,0 +1,81 @@
+"""customize events for logo management.
+
+we use one event to copy over custom logo images to _static
+and another even to link them in the html context
+"""
+from pathlib import Path
+
+from docutils.nodes import Node
+from sphinx.application import Sphinx
+from sphinx.errors import ExtensionError
+from sphinx.util import isurl, logging
+from sphinx.util.fileutil import copy_asset_file
+
+from .utils import get_theme_options_dict
+
+logger = logging.getLogger(__name__)
+
+
+def setup_logo_path(
+ app: Sphinx, pagename: str, templatename: str, context: dict, doctree: Node
+) -> None:
+ """Set up relative paths to logos in our HTML templates.
+
+ In Sphinx, the context["logo"] is a path to the `html_logo` image now in the output
+ `_static` folder.
+
+ If logo["image_light"] and logo["image_dark"] are given, we must modify them to
+ follow the same pattern. They have already been copied to the output folder
+ in the `update_config` event.
+ """
+ # get information from the context "logo_url" for sphinx>=6, "logo" sphinx<6
+ pathto = context.get("pathto")
+ logo = context.get("logo_url") or context.get("logo")
+ theme_logo = context.get("theme_logo", {})
+
+ # Define the final path to logo images in the HTML context
+ theme_logo["image_relative"] = {}
+ for kind in ["light", "dark"]:
+ image_kind_logo = theme_logo.get(f"image_{kind}")
+
+ # If it's a URL the "relative" path is just the URL
+ # else we need to calculate the relative path to a local file
+ if image_kind_logo:
+ if not isurl(image_kind_logo):
+ image_kind_name = Path(image_kind_logo).name
+ image_kind_logo = pathto(f"_static/{image_kind_name}", resource=True)
+ theme_logo["image_relative"][kind] = image_kind_logo
+
+ # If there's no custom logo for this kind, just use `html_logo`
+ # If `logo` is also None, then do not add this key to context.
+ elif isinstance(logo, str) and len(logo) > 0:
+ theme_logo["image_relative"][kind] = logo
+
+ # Update our context logo variables with the new image paths
+ context["theme_logo"] = theme_logo
+
+
+def copy_logo_images(app: Sphinx, exception=None) -> None:
+ """Copy logo image to the _static directory.
+
+ If logo image paths are given, copy them to the `_static` folder Then we can link to them directly in an html_page_context event.
+ """
+ logo = get_theme_options_dict(app).get("logo", {})
+ staticdir = Path(app.builder.outdir) / "_static"
+ for kind in ["light", "dark"]:
+ path_image = logo.get(f"image_{kind}")
+ if not path_image or isurl(path_image):
+ continue
+ if (staticdir / Path(path_image).name).exists():
+ # file already exists in static dir e.g. because a theme has
+ # bundled the logo and installed it there
+ continue
+ if not (Path(app.srcdir) / path_image).exists():
+ logger.warning(f"Path to {kind} image logo does not exist: {path_image}")
+ # Ensure templates cannot be passed for logo path to avoid security vulnerability
+ if path_image.lower().endswith("_t"):
+ raise ExtensionError(
+ f"The {kind} logo path '{path_image}' looks like a Sphinx template; "
+ "please provide a static logo image."
+ )
+ copy_asset_file(path_image, staticdir)
diff --git a/src/pydata_sphinx_theme/pygment.py b/src/pydata_sphinx_theme/pygment.py
new file mode 100644
index 000000000..b4d3c602d
--- /dev/null
+++ b/src/pydata_sphinx_theme/pygment.py
@@ -0,0 +1,103 @@
+"""Handle pygment css.
+
+inspired by the Furo theme
+https://github.com/pradyunsg/furo/blob/main/src/furo/__init__.py
+"""
+from pathlib import Path
+
+from pygments.formatters import HtmlFormatter
+from pygments.styles import get_all_styles
+from sphinx.application import Sphinx
+from sphinx.util import logging
+
+from .utils import get_theme_options_dict
+
+logger = logging.getLogger(__name__)
+
+
+def _get_styles(formatter: HtmlFormatter, prefix: str) -> None:
+ """Get styles out of a formatter, where everything has the correct prefix."""
+ for line in formatter.get_linenos_style_defs():
+ yield f"{prefix} {line}"
+ yield from formatter.get_background_style_defs(prefix)
+ yield from formatter.get_token_style_defs(prefix)
+
+
+def get_pygments_stylesheet(light_style: str, dark_style: str) -> str:
+ """Generate the theme-specific pygments.css.
+
+ There is no way to tell Sphinx how the theme handles modes.
+ """
+ light_formatter = HtmlFormatter(style=light_style)
+ dark_formatter = HtmlFormatter(style=dark_style)
+
+ lines = []
+
+ light_prefix = 'html[data-theme="light"] .highlight'
+ lines.extend(_get_styles(light_formatter, prefix=light_prefix))
+
+ dark_prefix = 'html[data-theme="dark"] .highlight'
+ lines.extend(_get_styles(dark_formatter, prefix=dark_prefix))
+
+ return "\n".join(lines)
+
+
+def overwrite_pygments_css(app: Sphinx, exception=None):
+ """Overwrite pygments.css to allow dynamic light/dark switching.
+
+ Sphinx natively supports config variables `pygments_style` and
+ `pygments_dark_style`. However, quoting from
+ www.sphinx-doc.org/en/master/development/theming.html#creating-themes
+
+ The pygments_dark_style setting [...is used] when the CSS media query
+ (prefers-color-scheme: dark) evaluates to true.
+
+ This does not allow for dynamic switching by the user, so at build time we
+ overwrite the pygment.css file so that it embeds 2 versions:
+
+ - the light theme prefixed with "[data-theme="light"]"
+ - the dark theme prefixed with "[data-theme="dark"]"
+
+ Fallbacks are defined in this function in case the user-requested (or our
+ theme-specified) pygments theme is not available.
+ """
+ if exception is not None:
+ return
+
+ assert app.builder
+
+ pygments_styles = list(get_all_styles())
+ fallbacks = dict(light="tango", dark="monokai")
+
+ for light_or_dark, fallback in fallbacks.items():
+ # make sure our fallbacks work; if not fall(further)back to "default"
+ if fallback not in pygments_styles:
+ fallback = pygments_styles[0] # should resolve to "default"
+
+ # see if user specified a light/dark pygments theme:
+ style_key = f"pygment_{light_or_dark}_style"
+ style_name = get_theme_options_dict(app).get(style_key, None)
+ # if not, use the one we set in `theme.conf`:
+ if style_name is None and hasattr(app.builder, "theme"):
+ style_name = app.builder.theme.get_options()[style_key]
+
+ # make sure we can load the style
+ if style_name not in pygments_styles:
+ # only warn if user asked for a highlight theme that we can't find
+ if style_name is not None:
+ logger.warning(
+ f"Highlighting style {style_name} not found by pygments, "
+ f"falling back to {fallback}."
+ )
+ style_name = fallback
+
+ # assign to the appropriate variable
+ if light_or_dark == "light":
+ light_theme = style_name
+ else:
+ dark_theme = style_name
+
+ # re-write pygments.css
+ pygment_css = Path(app.builder.outdir) / "_static" / "pygments.css"
+ with pygment_css.open("w") as f:
+ f.write(get_pygments_stylesheet(light_theme, dark_theme))
diff --git a/src/pydata_sphinx_theme/short_link.py b/src/pydata_sphinx_theme/short_link.py
new file mode 100644
index 000000000..052cef99b
--- /dev/null
+++ b/src/pydata_sphinx_theme/short_link.py
@@ -0,0 +1,98 @@
+"""A custom Transform object to shorten github and gitlab links."""
+
+from urllib.parse import ParseResult, urlparse, urlunparse
+
+from docutils import nodes
+from sphinx.transforms.post_transforms import SphinxPostTransform
+from sphinx.util.nodes import NodeMatcher
+
+from .utils import traverse_or_findall
+
+
+class ShortenLinkTransform(SphinxPostTransform):
+ """Shorten link when they are coming from github or gitlab and add an extra class to the tag for further styling.
+
+ Before::
+
+ https://github.com/2i2c-org/infrastructure/issues/1329
+
+ After::
+
+ 2i2c-org/infrastructure#1329
+
+ """
+
+ default_priority = 400
+ formats = ("html",)
+ supported_platform = {"github.com": "github", "gitlab.com": "gitlab"}
+ platform = None
+
+ def run(self, **kwargs):
+ """run the Transform object."""
+ matcher = NodeMatcher(nodes.reference)
+ # TODO: just use "findall" once docutils min version >=0.18.1
+ for node in traverse_or_findall(self.document, matcher):
+ uri = node.attributes.get("refuri")
+ text = next(iter(node.children), None)
+ # only act if the uri and text are the same
+ # if not the user has already customized the display of the link
+ if uri is not None and text is not None and text == uri:
+ uri = urlparse(uri)
+ # only do something if the platform is identified
+ self.platform = self.supported_platform.get(uri.netloc)
+ if self.platform is not None:
+ node.attributes["classes"].append(self.platform)
+ node.children[0] = nodes.Text(self.parse_url(uri))
+
+ def parse_url(self, uri: ParseResult) -> str:
+ """Parse the content of the url with respect to the selected platform."""
+ path = uri.path
+
+ if path == "":
+ # plain url passed, return platform only
+ return self.platform
+
+ # if the path is not empty it contains a leading "/", which we don't want to
+ # include in the parsed content
+ path = path.lstrip("/")
+
+ # check the platform name and read the information accordingly
+ # as "/#"
+ # or "//…//#"
+ if self.platform == "github":
+ # split the url content
+ parts = path.split("/")
+
+ if parts[0] == "orgs" and "/projects" in path:
+ # We have a projects board link
+ # ref: `orgs/{org}/projects/{project-id}`
+ text = f"{parts[1]}/projects#{parts[3]}"
+ else:
+ # We have an issues, PRs, or repository link
+ if len(parts) > 0:
+ text = parts[0] # organisation
+ if len(parts) > 1:
+ text += f"/{parts[1]}" # repository
+ if len(parts) > 2:
+ if parts[2] in ["issues", "pull", "discussions"]:
+ text += f"#{parts[-1]}" # element number
+
+ elif self.platform == "gitlab":
+ # cp. https://docs.gitlab.com/ee/user/markdown.html#gitlab-specific-references
+ if "/-/" in path and any(
+ map(uri.path.__contains__, ["issues", "merge_requests"])
+ ):
+ group_and_subgroups, parts, *_ = path.split("/-/")
+ parts = parts.split("/")
+ url_type, element_number, *_ = parts
+ if url_type == "issues":
+ text = f"{group_and_subgroups}#{element_number}"
+ elif url_type == "merge_requests":
+ text = f"{group_and_subgroups}!{element_number}"
+ else:
+ # display the whole uri (after "gitlab.com/") including parameters
+ # for example "///"
+ text = uri._replace(netloc="", scheme="") # remove platform
+ text = urlunparse(text)[1:] # combine to string and strip leading "/"
+
+ return text
diff --git a/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/.gitignore b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/.gitignore
index dd887f012..16a48c80b 100644
--- a/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/.gitignore
+++ b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/.gitignore
@@ -6,6 +6,7 @@ styles/pydata-sphinx-theme.css.map
# bootstrap generated webpack outputs
scripts/bootstrap.js
+scripts/bootstrap.js.LICENSE.txt
scripts/bootstrap.js.map
styles/bootstrap.css
styles/bootstrap.css.map
diff --git a/src/pydata_sphinx_theme/toctree.py b/src/pydata_sphinx_theme/toctree.py
new file mode 100644
index 000000000..a9d9c6796
--- /dev/null
+++ b/src/pydata_sphinx_theme/toctree.py
@@ -0,0 +1,433 @@
+"""Methods to build the toctree used in the html pages."""
+
+from functools import lru_cache
+from typing import List, Union
+from urllib.parse import urlparse
+
+from bs4 import BeautifulSoup
+from docutils import nodes
+from docutils.nodes import Node
+from sphinx import addnodes
+from sphinx.addnodes import toctree as toctree_node
+from sphinx.application import Sphinx
+from sphinx.environment.adapters.toctree import TocTree
+
+
+def add_inline_math(node: Node) -> str:
+ """Render a node with HTML tags that activate MathJax processing.
+
+ This is meant for use with rendering section titles with math in them, because
+ math outputs are ignored by pydata-sphinx-theme's header.
+
+ related to the behaviour of a normal math node from:
+ https://github.com/sphinx-doc/sphinx/blob/master/sphinx/ext/mathjax.py#L28
+ """
+ return (
+ '' rf"\({node.astext()}\)" ""
+ )
+
+
+def add_toctree_functions(
+ app: Sphinx, pagename: str, templatename: str, context, doctree
+) -> None:
+ """Add functions so Jinja templates can add toctree objects."""
+
+ @lru_cache(maxsize=None)
+ def generate_header_nav_html(n_links_before_dropdown: int = 5) -> str:
+ """Generate top-level links that are meant for the header navigation.
+
+ We use this function instead of the TocTree-based one used for the
+ sidebar because this one is much faster for generating the links and
+ we don't need the complexity of the full Sphinx TocTree.
+
+ This includes two kinds of links:
+
+ - Links to pages described listed in the root_doc TocTrees
+ - External links defined in theme configuration
+
+ Additionally it will create a dropdown list for several links after
+ a cutoff.
+
+ Parameters:
+ n_links_before_dropdown:The number of links to show before nesting the remaining links in a Dropdown element.
+ """
+ try:
+ n_links_before_dropdown = int(n_links_before_dropdown)
+ except Exception:
+ raise ValueError(
+ f"n_links_before_dropdown is not an int: {n_links_before_dropdown}"
+ )
+ toctree = TocTree(app.env)
+
+ # Find the active header navigation item so we decide whether to highlight
+ # Will be empty if there is no active page (root_doc, or genindex etc)
+ active_header_page = toctree.get_toctree_ancestors(pagename)
+ if active_header_page:
+ # The final list item will be the top-most ancestor
+ active_header_page = active_header_page[-1]
+
+ # Find the root document because it lists our top-level toctree pages
+ root = app.env.tocs[app.config.root_doc]
+
+ # Iterate through each toctree node in the root document
+ # Grab the toctree pages and find the relative link + title.
+ links_html = []
+ # TODO: just use "findall" once docutils min version >=0.18.1
+ meth = "findall" if hasattr(root, "findall") else "traverse"
+ for toc in getattr(root, meth)(toctree_node):
+ for title, page in toc.attributes["entries"]:
+ # if the page is using "self" use the correct link
+ page = toc.attributes["parent"] if page == "self" else page
+
+ # If this is the active ancestor page, add a class so we highlight it
+ current = " current active" if page == active_header_page else ""
+
+ # sanitize page title for use in the html output if needed
+ if title is None:
+ title = ""
+ for node in app.env.titles[page].children:
+ if isinstance(node, nodes.math):
+ title += add_inline_math(node)
+ else:
+ title += node.astext()
+
+ # set up the status of the link and the path
+ # if the path is relative then we use the context for the path
+ # resolution and the internal class.
+ # If it's an absolute one then we use the external class and
+ # the complete url.
+ is_absolute = bool(urlparse(page).netloc)
+ link_status = "external" if is_absolute else "internal"
+ link_href = page if is_absolute else context["pathto"](page)
+
+ # create the html output
+ links_html.append(
+ f"""
+
+ """
+ )
+
+ # Add external links defined in configuration as sibling list items
+ for external_link in context["theme_external_links"]:
+ links_html.append(
+ f"""
+
+ """
+ )
+
+ # The first links will always be visible
+ links_solo = links_html[:n_links_before_dropdown]
+ out = "\n".join(links_solo)
+
+ # Wrap the final few header items in a "more" dropdown
+ links_dropdown = links_html[n_links_before_dropdown:]
+ if links_dropdown:
+ links_dropdown_html = "\n".join(links_dropdown)
+ out += f"""
+
+
+
+ {links_dropdown_html}
+
+
+ """
+
+ return out
+
+ # Cache this function because it is expensive to run, and because Sphinx
+ # somehow runs this twice in some circumstances in unpredictable ways.
+ @lru_cache(maxsize=None)
+ def generate_toctree_html(
+ kind: str, startdepth: int = 1, show_nav_level: int = 1, **kwargs
+ ) -> Union[BeautifulSoup, str]:
+ """Return the navigation link structure in HTML.
+
+ This is similar to Sphinx's own default TocTree generation, but it is modified
+ to generate TocTrees for *second*-level pages and below (not supported
+ by default in Sphinx).
+ This is used for our sidebar, which starts at the second-level page.
+
+ It also modifies the generated TocTree slightly for Bootstrap classes
+ and structure (via BeautifulSoup).
+
+ Arguments are passed to Sphinx "toctree" function (context["toctree"] below).
+
+ ref: https://www.sphinx-doc.org/en/master/templating.html#toctree
+
+ Parameters:
+ kind : "sidebar" or "raw". Whether to generate HTML meant for sidebar navigation ("sidebar") or to return the raw BeautifulSoup object ("raw").
+ startdepth : The level of the toctree at which to start. By default, for the navbar uses the normal toctree (`startdepth=0`), and for the sidebar starts from the second level (`startdepth=1`).
+ show_nav_level : The level of the navigation bar to toggle as visible on page load. By default, this level is 1, and only top-level pages are shown, with drop-boxes to reveal children. Increasing `show_nav_level` will show child levels as well.
+ kwargs: passed to the Sphinx `toctree` template function.
+
+ Returns:
+ HTML string (if kind == "sidebar") OR BeautifulSoup object (if kind == "raw")
+ """
+ if startdepth == 0:
+ toc_sphinx = context["toctree"](**kwargs)
+ else:
+ # select the "active" subset of the navigation tree for the sidebar
+ toc_sphinx = index_toctree(app, pagename, startdepth, **kwargs)
+
+ soup = BeautifulSoup(toc_sphinx, "html.parser")
+
+ # pair "current" with "active" since that's what we use w/ bootstrap
+ for li in soup("li", {"class": "current"}):
+ li["class"].append("active")
+
+ # Remove sidebar links to sub-headers on the page
+ for li in soup.select("li"):
+ # Remove
+ if li.find("a"):
+ href = li.find("a")["href"]
+ if "#" in href and href != "#":
+ li.decompose()
+
+ if kind == "sidebar":
+ # Add bootstrap classes for first `ul` items
+ for ul in soup("ul", recursive=False):
+ ul.attrs["class"] = ul.attrs.get("class", []) + ["nav", "bd-sidenav"]
+
+ # Add collapse boxes for parts/captions.
+ # Wraps the TOC part in an extra
to behave like chapters with toggles
+ # show_nav_level: 0 means make parts collapsible.
+ if show_nav_level == 0:
+ partcaptions = soup.find_all("p", attrs={"class": "caption"})
+ if len(partcaptions):
+ new_soup = BeautifulSoup(
+ "
", "html.parser"
+ )
+ for caption in partcaptions:
+ # Assume that the next
element is the TOC list
+ # for this part
+ for sibling in caption.next_siblings:
+ if sibling.name == "ul":
+ toclist = sibling
+ break
+ li = soup.new_tag("li", attrs={"class": "toctree-l0"})
+ li.extend([caption, toclist])
+ new_soup.ul.append(li)
+ soup = new_soup
+
+ # Add icons and labels for collapsible nested sections
+ add_collapse_checkboxes(soup)
+
+ # Open the sidebar navigation to the proper depth
+ for ii in range(int(show_nav_level)):
+ for checkbox in soup.select(
+ f"li.toctree-l{ii} > input.toctree-checkbox"
+ ):
+ checkbox.attrs["checked"] = None
+
+ return soup
+
+ @lru_cache(maxsize=None)
+ def generate_toc_html(kind: str = "html") -> BeautifulSoup:
+ """Return the within-page TOC links in HTML."""
+ if "toc" not in context:
+ return ""
+
+ soup = BeautifulSoup(context["toc"], "html.parser")
+
+ # Add toc-hN + visible classes
+ def add_header_level_recursive(ul, level):
+ if ul is None:
+ return
+ if level <= (context["theme_show_toc_level"] + 1):
+ ul["class"] = ul.get("class", []) + ["visible"]
+ for li in ul("li", recursive=False):
+ li["class"] = li.get("class", []) + [f"toc-h{level}"]
+ add_header_level_recursive(li.find("ul", recursive=False), level + 1)
+
+ add_header_level_recursive(soup.find("ul"), 1)
+
+ # Add in CSS classes for bootstrap
+ for ul in soup("ul"):
+ ul["class"] = ul.get("class", []) + ["nav", "section-nav", "flex-column"]
+
+ for li in soup("li"):
+ li["class"] = li.get("class", []) + ["nav-item", "toc-entry"]
+ if li.find("a"):
+ a = li.find("a")
+ a["class"] = a.get("class", []) + ["nav-link"]
+
+ # If we only have one h1 header, assume it's a title
+ h1_headers = soup.select(".toc-h1")
+ if len(h1_headers) == 1:
+ title = h1_headers[0]
+ # If we have no sub-headers of a title then we won't have a TOC
+ if not title.select(".toc-h2"):
+ out = ""
+ else:
+ out = title.find("ul")
+ # Else treat the h1 headers as sections
+ else:
+ out = soup
+
+ # Return the toctree object
+ if kind == "html":
+ return out
+ else:
+ return soup
+
+ def navbar_align_class() -> List[str]:
+ """Return the class that aligns the navbar based on config."""
+ align = context.get("theme_navbar_align", "content")
+ align_options = {
+ "content": ("col-lg-9", "me-auto"),
+ "left": ("", "me-auto"),
+ "right": ("", "ms-auto"),
+ }
+ if align not in align_options:
+ raise ValueError(
+ "Theme option navbar_align must be one of"
+ f"{align_options.keys()}, got: {align}"
+ )
+ return align_options[align]
+
+ context["generate_header_nav_html"] = generate_header_nav_html
+ context["generate_toctree_html"] = generate_toctree_html
+ context["generate_toc_html"] = generate_toc_html
+ context["navbar_align_class"] = navbar_align_class
+
+
+def add_collapse_checkboxes(soup: BeautifulSoup) -> None:
+ """Add checkboxes to collapse children in a toctree."""
+ # based on https://github.com/pradyunsg/furo
+
+ toctree_checkbox_count = 0
+
+ for element in soup.find_all("li", recursive=True):
+ # We check all "li" elements, to add a "current-page" to the correct li.
+ classes = element.get("class", [])
+
+ # expanding the parent part explicitly, if present
+ if "current" in classes:
+ parentli = element.find_parent("li", class_="toctree-l0")
+ if parentli:
+ parentli.select("p.caption ~ input")[0].attrs["checked"] = ""
+
+ # Nothing more to do, unless this has "children"
+ if not element.find("ul"):
+ continue
+
+ # Add a class to indicate that this has children.
+ element["class"] = classes + ["has-children"]
+
+ # We're gonna add a checkbox.
+ toctree_checkbox_count += 1
+ checkbox_name = f"toctree-checkbox-{toctree_checkbox_count}"
+
+ # Add the "label" for the checkbox which will get filled.
+ if soup.new_tag is None:
+ continue
+
+ label = soup.new_tag(
+ "label", attrs={"for": checkbox_name, "class": "toctree-toggle"}
+ )
+ label.append(soup.new_tag("i", attrs={"class": "fa-solid fa-chevron-down"}))
+ if "toctree-l0" in classes:
+ # making label cover the whole caption text with css
+ label["class"] = "label-parts"
+ element.insert(1, label)
+
+ # Add the checkbox that's used to store expanded/collapsed state.
+ checkbox = soup.new_tag(
+ "input",
+ attrs={
+ "type": "checkbox",
+ "class": ["toctree-checkbox"],
+ "id": checkbox_name,
+ "name": checkbox_name,
+ },
+ )
+
+ # if this has a "current" class, be expanded by default
+ # (by checking the checkbox)
+ if "current" in classes:
+ checkbox.attrs["checked"] = ""
+
+ element.insert(1, checkbox)
+
+
+def get_local_toctree_for(
+ self: TocTree, indexname: str, docname: str, builder, collapse: bool, **kwargs
+) -> List[BeautifulSoup]:
+ """Return the "local" TOC nodetree (relative to `indexname`)."""
+ # this is a copy of `TocTree.get_toctree_for`, but where the sphinx version
+ # always uses the "root" doctree:
+ # doctree = self.env.get_doctree(self.env.config.root_doc)
+ # we here use the `indexname` additional argument to be able to use a subset
+ # of the doctree (e.g. starting at a second level for the sidebar):
+ # doctree = app.env.tocs[indexname].deepcopy()
+
+ doctree = self.env.tocs[indexname].deepcopy()
+
+ toctrees = []
+ if "includehidden" not in kwargs:
+ kwargs["includehidden"] = True
+ if "maxdepth" not in kwargs or not kwargs["maxdepth"]:
+ kwargs["maxdepth"] = 0
+ else:
+ kwargs["maxdepth"] = int(kwargs["maxdepth"])
+ kwargs["collapse"] = collapse
+
+ # FIX: Can just use "findall" once docutils 0.18+ is required
+ meth = "findall" if hasattr(doctree, "findall") else "traverse"
+ for toctreenode in getattr(doctree, meth)(addnodes.toctree):
+ toctree = self.resolve(docname, builder, toctreenode, prune=True, **kwargs)
+ if toctree:
+ toctrees.append(toctree)
+ if not toctrees:
+ return None
+ result = toctrees[0]
+ for toctree in toctrees[1:]:
+ result.extend(toctree.children)
+ return result
+
+
+def index_toctree(
+ app: Sphinx, pagename: str, startdepth: int, collapse: bool = True, **kwargs
+):
+ """Returns the "local" (starting at `startdepth`) TOC tree containing the current page, rendered as HTML bullet lists.
+
+ This is the equivalent of `context["toctree"](**kwargs)` in sphinx
+ templating, but using the startdepth-local instead of global TOC tree.
+ """
+ # this is a variant of the function stored in `context["toctree"]`, which is
+ # defined as `lambda **kwargs: self._get_local_toctree(pagename, **kwargs)`
+ # with `self` being the HMTLBuilder and the `_get_local_toctree` basically
+ # returning:
+ # return self.render_partial(TocTree(self.env).get_toctree_for(
+ # pagename, self, collapse, **kwargs))['fragment']
+
+ if "includehidden" not in kwargs:
+ kwargs["includehidden"] = False
+ if kwargs.get("maxdepth") == "":
+ kwargs.pop("maxdepth")
+
+ toctree = TocTree(app.env)
+ ancestors = toctree.get_toctree_ancestors(pagename)
+ try:
+ indexname = ancestors[-startdepth]
+ except IndexError:
+ # eg for index.rst, but also special pages such as genindex, py-modindex, search
+ # those pages don't have a "current" element in the toctree, so we can
+ # directly return an empty string instead of using the default sphinx
+ # toctree.get_toctree_for(pagename, app.builder, collapse, **kwargs)
+ return ""
+
+ toctree_element = get_local_toctree_for(
+ toctree, indexname, pagename, app.builder, collapse, **kwargs
+ )
+ return app.builder.render_partial(toctree_element)["fragment"]
diff --git a/src/pydata_sphinx_theme/translator.py b/src/pydata_sphinx_theme/translator.py
index 3c9ef46a9..eb2046bd4 100644
--- a/src/pydata_sphinx_theme/translator.py
+++ b/src/pydata_sphinx_theme/translator.py
@@ -1,18 +1,19 @@
-"""
-A custom Sphinx HTML Translator for Bootstrap layout
-"""
-from packaging.version import Version
+"""A custom Sphinx HTML Translator for Bootstrap layout."""
+
+import types
import sphinx
-from sphinx.util import logging
+from packaging.version import Version
+from sphinx.application import Sphinx
from sphinx.ext.autosummary import autosummary_table
+from sphinx.util import logging
logger = logging.getLogger(__name__)
class BootstrapHTML5TranslatorMixin:
- """
- Mixin HTML Translator for a Bootstrap-ified Sphinx layout.
+ """Mixin HTML Translator for a Bootstrap-ified Sphinx layout.
+
Only a couple of functions have been overridden to produce valid HTML to be
directly styled with Bootstrap, and fulfill acessibility best practices.
"""
@@ -22,17 +23,16 @@ def __init__(self, *args, **kwds):
self.settings.table_style = "table"
def starttag(self, *args, **kwargs):
- """ensure an aria-level is set for any heading role"""
+ """Ensure an aria-level is set for any heading role."""
if kwargs.get("ROLE") == "heading" and "ARIA-LEVEL" not in kwargs:
kwargs["ARIA-LEVEL"] = "2"
return super().starttag(*args, **kwargs)
def visit_table(self, node):
- """
- copy of sphinx source to *not* add 'docutils' and 'align-default' classes
- but add 'table' class
- """
+ """Custom visit table method.
+ Copy of sphinx source to *not* add 'docutils' and 'align-default' classes but add 'table' class.
+ """
# init the attributes
atts = {}
@@ -58,3 +58,38 @@ def visit_table(self, node):
tag = self.starttag(node, "table", CLASS=" ".join(classes), **atts)
self.body.append(tag)
+
+
+def setup_translators(app: Sphinx):
+ """Add bootstrap HTML functionality if we are using an HTML translator.
+
+ This re-uses the pre-existing Sphinx translator and adds extra functionality defined
+ in ``BootstrapHTML5TranslatorMixin``. This way we can retain the original translator's
+ behavior and configuration, and _only_ add the extra bootstrap rules.
+ If we don't detect an HTML-based translator, then we do nothing.
+ """
+ if not app.registry.translators.items():
+ translator = types.new_class(
+ "BootstrapHTML5Translator",
+ (
+ BootstrapHTML5TranslatorMixin,
+ app.builder.default_translator_class,
+ ),
+ {},
+ )
+ app.set_translator(app.builder.name, translator, override=True)
+ else:
+ for name, klass in app.registry.translators.items():
+ if app.builder.format != "html":
+ # Skip translators that are not HTML
+ continue
+
+ translator = types.new_class(
+ "BootstrapHTML5Translator",
+ (
+ BootstrapHTML5TranslatorMixin,
+ klass,
+ ),
+ {},
+ )
+ app.set_translator(name, translator, override=True)
diff --git a/src/pydata_sphinx_theme/utils.py b/src/pydata_sphinx_theme/utils.py
new file mode 100644
index 000000000..6ec4f2084
--- /dev/null
+++ b/src/pydata_sphinx_theme/utils.py
@@ -0,0 +1,91 @@
+"""General helpers for the management of config parameters."""
+
+from typing import Any, Dict, Iterator
+
+from bs4 import BeautifulSoup, ResultSet
+from docutils.nodes import Node
+from sphinx.application import Sphinx
+
+
+def get_theme_options_dict(app: Sphinx) -> Dict[str, Any]:
+ """Return theme options for the application w/ a fallback if they don't exist.
+
+ The "top-level" mapping (the one we should usually check first, and modify
+ if desired) is ``app.builder.theme_options``. It is created by Sphinx as a
+ copy of ``app.config.html_theme_options`` (containing user-configs from
+ their ``conf.py``); sometimes that copy never occurs though which is why we
+ check both.
+ """
+ if hasattr(app.builder, "theme_options"):
+ return app.builder.theme_options
+ elif hasattr(app.config, "html_theme_options"):
+ return app.config.html_theme_options
+ else:
+ return {}
+
+
+def config_provided_by_user(app: Sphinx, key: str) -> bool:
+ """Check if the user has manually provided the config."""
+ return any(key in ii for ii in [app.config.overrides, app.config._raw_config])
+
+
+def soup_to_python(soup: BeautifulSoup, only_pages: bool = False) -> Dict[str, Any]:
+ """Convert the toctree html structure to python objects which can be used in Jinja.
+
+ Parameters:
+ soup : BeautifulSoup object for the toctree
+ only_pages : Only include items for full pages in the output dictionary. Exclude anchor links (TOC items with a URL that starts with #)
+
+ Returns:
+ The toctree, converted into a dictionary with key/values that work within Jinja.
+ """
+ # toctree has this structure (caption only for toctree, not toc)
+ #
+ # ...
+
+ def extract_level_recursive(ul: ResultSet, navs_list: list) -> None:
+ for li in ul.find_all("li", recursive=False):
+ ref = li.a
+ url = ref["href"]
+ title = "".join(map(str, ref.contents))
+ active = "current" in li.get("class", [])
+
+ # If we've got an anchor link, skip it if we wish
+ if only_pages and "#" in url and url != "#":
+ continue
+
+ # Converting the docutils attributes into jinja-friendly objects
+ nav = {}
+ nav["title"] = title
+ nav["url"] = url
+ nav["active"] = active
+
+ navs_list.append(nav)
+
+ # Recursively convert children as well
+ nav["children"] = []
+ ul = li.find("ul", recursive=False)
+ if ul:
+ extract_level_recursive(ul, nav["children"])
+
+ navs = []
+ for ul in soup.find_all("ul", recursive=False):
+ extract_level_recursive(ul, navs)
+
+ return navs
+
+
+def traverse_or_findall(node: Node, condition: str, **kwargs) -> Iterator[Node]:
+ """Triage node.traverse (docutils <0.18.1) vs node.findall.
+
+ TODO: This check can be removed when the minimum supported docutils version
+ for numpydoc is docutils>=0.18.1.
+ """
+ return (
+ node.findall(condition, **kwargs)
+ if hasattr(node, "findall")
+ else node.traverse(condition, **kwargs)
+ )
diff --git a/tests/check_warnings.py b/tests/check_warnings.py
index 1b08eb5b7..241f4acf1 100644
--- a/tests/check_warnings.py
+++ b/tests/check_warnings.py
@@ -1,5 +1,7 @@
-from pathlib import Path
+"""Check the list of warnings produced by a doc build."""
+
import sys
+from pathlib import Path
from colorama import Fore, init
@@ -7,20 +9,19 @@
init()
-def check_warnings(file):
- """
- Check the list of warnings produced by the GitHub CI tests
- raise errors if there are unexpected ones and/or if some are missing
+def check_warnings(file: Path) -> bool:
+ """Check the list of warnings produced by a doc build.
+
+ Raise errors if there are unexpected ones and/or if some are missing.
- Args:
- file (pathlib.Path): the path to the generated warning.txt file from
+ Parameters:
+ file: the path to the generated warning.txt file from
the CI build
- Return:
+ Returns:
0 if the warnings are all there
1 if some warning are not registered or unexpected
"""
-
# print some log
print("\n=== Sphinx Warnings test ===\n")
diff --git a/tests/conftest.py b/tests/conftest.py
index 970578a0e..6fd8155f6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1 +1,79 @@
+"""Configuration of the pytest session."""
+
+import re
+from pathlib import Path
+from shutil import copytree
+from typing import Callable
+
+import pytest
+from bs4 import BeautifulSoup
+from sphinx.testing.path import path as sphinx_path
+from sphinx.testing.util import SphinxTestApp
+from typing_extensions import Self
+
pytest_plugins = "sphinx.testing.fixtures"
+
+path_tests = Path(__file__).parent
+
+# -- Utils method ------------------------------------------------------------
+
+
+def escape_ansi(string: str) -> str:
+ """Helper function to remove ansi coloring from sphinx warnings."""
+ ansi_escape = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]")
+ return ansi_escape.sub("", string)
+
+
+# -- global fixture to build sphinx tmp docs ---------------------------------
+
+
+class SphinxBuild:
+ """Helper class to build a test documentation."""
+
+ def __init__(self, app: SphinxTestApp, src: Path):
+ self.app = app
+ self.src = src
+
+ def build(self, no_warning: bool = True) -> Self:
+ """Build the application."""
+ self.app.build()
+ if no_warning is True:
+ assert self.warnings == "", self.status
+ return self
+
+ @property
+ def status(self) -> str:
+ """Returns the status of the current build."""
+ return self.app._status.getvalue()
+
+ @property
+ def warnings(self) -> str:
+ """Returns the warnings raised by the current build."""
+ return self.app._warning.getvalue()
+
+ @property
+ def outdir(self) -> Path:
+ """Returns the output directory of the current build."""
+ return Path(self.app.outdir)
+
+ def html_tree(self, *path) -> str:
+ """Returns the html tree of the current build."""
+ path_page = self.outdir.joinpath(*path)
+ if not path_page.exists():
+ raise ValueError(f"{path_page} does not exist")
+ return BeautifulSoup(path_page.read_text("utf8"), "html.parser")
+
+
+@pytest.fixture()
+def sphinx_build_factory(make_app: Callable, tmp_path: Path) -> Callable:
+ """Return a factory builder pointing to the tmp directory."""
+
+ def _func(src_folder: Path, **kwargs) -> SphinxBuild:
+ """Create the Sphinxbuild from the source folder."""
+ copytree(path_tests / "sites" / src_folder, tmp_path / src_folder)
+ app = make_app(
+ srcdir=sphinx_path(Path(tmp_path / src_folder).resolve()), **kwargs
+ )
+ return SphinxBuild(app, tmp_path / src_folder)
+
+ yield _func
diff --git a/tests/sites/base/conf.py b/tests/sites/base/conf.py
index 27501e5b9..cb10ad0ef 100644
--- a/tests/sites/base/conf.py
+++ b/tests/sites/base/conf.py
@@ -1,3 +1,5 @@
+"""Test conf file."""
+
# -- Project information -----------------------------------------------------
project = "PyData Tests"
diff --git a/tests/sites/deprecated/conf.py b/tests/sites/deprecated/conf.py
index ce8d1dd08..0bf42a938 100644
--- a/tests/sites/deprecated/conf.py
+++ b/tests/sites/deprecated/conf.py
@@ -1,3 +1,5 @@
+"""Test conf file."""
+
# -- Project information -----------------------------------------------------
project = "PyData Tests"
diff --git a/tests/sites/sidebars/conf.py b/tests/sites/sidebars/conf.py
index 504e54d38..ff475aee2 100644
--- a/tests/sites/sidebars/conf.py
+++ b/tests/sites/sidebars/conf.py
@@ -1,3 +1,5 @@
+"""Test conf file."""
+
# -- Project information -----------------------------------------------------
project = "PyData Tests"
diff --git a/tests/sites/test_included_toc/conf.py b/tests/sites/test_included_toc/conf.py
index 8d681b914..d01436048 100644
--- a/tests/sites/test_included_toc/conf.py
+++ b/tests/sites/test_included_toc/conf.py
@@ -1,3 +1,5 @@
+"""Test conf file."""
+
# -- Project information -----------------------------------------------------
project = "Test"
diff --git a/tests/sites/test_navbar_no_in_page_headers/conf.py b/tests/sites/test_navbar_no_in_page_headers/conf.py
index d56059e28..a7c9f95c8 100644
--- a/tests/sites/test_navbar_no_in_page_headers/conf.py
+++ b/tests/sites/test_navbar_no_in_page_headers/conf.py
@@ -1,3 +1,5 @@
+"""Test conf file."""
+
# -- Project information -----------------------------------------------------
project = "PyData Tests"
diff --git a/tests/test_build.py b/tests/test_build.py
index 5706dbbc7..d49a3d0e1 100644
--- a/tests/test_build.py
+++ b/tests/test_build.py
@@ -1,69 +1,21 @@
-import os
+"""All the tests performed in the pydata-sphinx-theme test suit."""
+
import re
from pathlib import Path
-from shutil import copytree
import pytest
import sphinx.errors
-from bs4 import BeautifulSoup
-from sphinx.testing.path import path as sphinx_path
-from sphinx.testing.util import SphinxTestApp
-
-path_tests = Path(__file__).parent
-def escape_ansi(string):
- """helper function to remove ansi coloring from sphinx warnings"""
+def escape_ansi(string: str) -> str:
+ """Helper function to remove ansi coloring from sphinx warnings."""
ansi_escape = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]")
return ansi_escape.sub("", string)
-class SphinxBuild:
- def __init__(self, app: SphinxTestApp, src: Path):
- self.app = app
- self.src = src
-
- def build(self, no_warning=True):
- self.app.build()
- if no_warning is True:
- assert self.warnings == "", self.status
- return self
-
- @property
- def status(self):
- return self.app._status.getvalue()
-
- @property
- def warnings(self):
- return self.app._warning.getvalue()
-
- @property
- def outdir(self):
- return Path(self.app.outdir)
-
- def html_tree(self, *path):
- path_page = self.outdir.joinpath(*path)
- if not path_page.exists():
- raise ValueError(f"{path_page} does not exist")
- return BeautifulSoup(path_page.read_text("utf8"), "html.parser")
-
-
-@pytest.fixture()
-def sphinx_build_factory(make_app, tmp_path):
- def _func(src_folder, **kwargs):
- copytree(path_tests / "sites" / src_folder, tmp_path / src_folder)
- app = make_app(
- srcdir=sphinx_path(os.path.abspath(tmp_path / src_folder)), **kwargs
- )
- return SphinxBuild(app, tmp_path / src_folder)
-
- yield _func
-
-
-def test_build_html(sphinx_build_factory, file_regression):
+def test_build_html(sphinx_build_factory, file_regression) -> None:
"""Test building the base html template and config."""
-
- sphinx_build = sphinx_build_factory("base") # type: SphinxBuild
+ sphinx_build = sphinx_build_factory("base")
# Basic build with defaults
sphinx_build.build()
@@ -89,8 +41,8 @@ def test_build_html(sphinx_build_factory, file_regression):
assert not sphinx_build.html_tree("page2.html").select("div.bd-sidebar-secondary")
-def test_toc_visibility(sphinx_build_factory):
- # Test that setting TOC level visibility works as expected
+def test_toc_visibility(sphinx_build_factory) -> None:
+ """Test that setting TOC level visibility works as expected."""
confoverrides = {
"html_theme_options.show_toc_level": 2,
}
@@ -102,7 +54,8 @@ def test_toc_visibility(sphinx_build_factory):
assert "visible" not in index_html.select(".toc-h3 ul")[0].attrs["class"]
-def test_icon_links(sphinx_build_factory, file_regression):
+def test_icon_links(sphinx_build_factory, file_regression) -> None:
+ """Test that setting icon links are rendered in the documentation."""
html_theme_options_icon_links = {
"icon_links": [
{
@@ -162,7 +115,7 @@ def test_icon_links(sphinx_build_factory, file_regression):
)
-def test_logo_basic(sphinx_build_factory):
+def test_logo_basic(sphinx_build_factory) -> None:
"""Test that the logo is shown by default, project title if no logo."""
sphinx_build = sphinx_build_factory("base").build()
@@ -173,7 +126,7 @@ def test_logo_basic(sphinx_build_factory):
assert not index_html.select(".navbar-brand")[0].text.strip()
-def test_logo_no_image(sphinx_build_factory):
+def test_logo_no_image(sphinx_build_factory) -> None:
"""Test that the text is shown if no image specified."""
confoverrides = {"html_logo": ""}
sphinx_build = sphinx_build_factory("base", confoverrides=confoverrides).build()
@@ -182,7 +135,7 @@ def test_logo_no_image(sphinx_build_factory):
assert "emptylogo" not in str(index_html.select(".navbar-brand")[0])
-def test_logo_two_images(sphinx_build_factory):
+def test_logo_two_images(sphinx_build_factory) -> None:
"""Test that the logo image / text is correct when both dark / light given."""
# Test with a specified title and a dark logo
confoverrides = {
@@ -201,10 +154,8 @@ def test_logo_two_images(sphinx_build_factory):
assert "Foo Title" in index_str
-def test_primary_logo_is_light_when_no_default_mode(sphinx_build_factory):
- """Test that the primary logo image is light
- (and secondary, written through JavaScript, is dark)
- when no default mode is set."""
+def test_primary_logo_is_light_when_no_default_mode(sphinx_build_factory) -> None:
+ """Test that the primary logo image is light when no default mode is set."""
# Ensure no default mode is set
confoverrides = {
"html_context": {},
@@ -216,10 +167,10 @@ def test_primary_logo_is_light_when_no_default_mode(sphinx_build_factory):
assert navbar_brand.find("script", string=re.compile("only-dark")) is not None
-def test_primary_logo_is_light_when_default_mode_is_set_to_auto(sphinx_build_factory):
- """Test that the primary logo image is light
- (and secondary, written through JavaScript, is dark)
- when default mode is explicitly set to auto."""
+def test_primary_logo_is_light_when_default_mode_is_set_to_auto(
+ sphinx_build_factory,
+) -> None:
+ """Test that the primary logo image is light when default is set to auto."""
# Ensure no default mode is set
confoverrides = {
"html_context": {"default_mode": "auto"},
@@ -231,10 +182,8 @@ def test_primary_logo_is_light_when_default_mode_is_set_to_auto(sphinx_build_fac
assert navbar_brand.find("script", string=re.compile("only-dark")) is not None
-def test_primary_logo_is_light_when_default_mode_is_light(sphinx_build_factory):
- """Test that the primary logo image is light
- (and secondary, written through JavaScript, is dark)
- when default mode is set to light."""
+def test_primary_logo_is_light_when_default_mode_is_light(sphinx_build_factory) -> None:
+ """Test that the primary logo image is light when default mode is set to light."""
# Ensure no default mode is set
confoverrides = {
"html_context": {"default_mode": "light"},
@@ -246,10 +195,8 @@ def test_primary_logo_is_light_when_default_mode_is_light(sphinx_build_factory):
assert navbar_brand.find("script", string=re.compile("only-dark")) is not None
-def test_primary_logo_is_dark_when_default_mode_is_dark(sphinx_build_factory):
- """Test that the primary logo image is dark
- (and secondary, written through JavaScript, is light)
- when default mode is set to dark."""
+def test_primary_logo_is_dark_when_default_mode_is_dark(sphinx_build_factory) -> None:
+ """Test that the primary logo image is dark when default mode is set to dark."""
# Ensure no default mode is set
confoverrides = {
"html_context": {"default_mode": "dark"},
@@ -261,7 +208,7 @@ def test_primary_logo_is_dark_when_default_mode_is_dark(sphinx_build_factory):
assert navbar_brand.find("script", string=re.compile("only-light")) is not None
-def test_logo_missing_image(sphinx_build_factory):
+def test_logo_missing_image(sphinx_build_factory) -> None:
"""Test that a missing image will raise a warning."""
# Test with a specified title and a dark logo
confoverrides = {
@@ -278,7 +225,7 @@ def test_logo_missing_image(sphinx_build_factory):
assert "image logo does not exist" in escape_ansi(sphinx_build.warnings).strip()
-def test_logo_external_link(sphinx_build_factory):
+def test_logo_external_link(sphinx_build_factory) -> None:
"""Test that the logo link is correct for external URLs."""
# Test with a specified external logo link
test_url = "https://secure.example.com"
@@ -295,7 +242,7 @@ def test_logo_external_link(sphinx_build_factory):
assert f'href="{test_url}"' in index_str
-def test_logo_external_image(sphinx_build_factory):
+def test_logo_external_image(sphinx_build_factory) -> None:
"""Test that the logo link is correct for external URLs."""
# Test with a specified external logo image source
test_url = "https://pydata.org/wp-content/uploads/2019/06/pydata-logo-final.png"
@@ -312,8 +259,8 @@ def test_logo_external_image(sphinx_build_factory):
assert f'src="{test_url}"' in index_str
-def test_logo_template_rejected(sphinx_build_factory):
- """Test that dynamic Sphinx templates are not accepted as logo files"""
+def test_logo_template_rejected(sphinx_build_factory) -> None:
+ """Test that dynamic Sphinx templates are not accepted as logo files."""
# Test with a specified external logo image source
confoverrides = {
"html_theme_options": {
@@ -326,14 +273,14 @@ def test_logo_template_rejected(sphinx_build_factory):
sphinx_build_factory("base", confoverrides=confoverrides).build()
-def test_navbar_align_default(sphinx_build_factory):
+def test_navbar_align_default(sphinx_build_factory) -> None:
"""The navbar items align with the proper part of the page."""
sphinx_build = sphinx_build_factory("base").build()
index_html = sphinx_build.html_tree("index.html")
assert "col-lg-9" in index_html.select(".navbar-header-items")[0].attrs["class"]
-def test_navbar_align_right(sphinx_build_factory):
+def test_navbar_align_right(sphinx_build_factory) -> None:
"""The navbar items align with the proper part of the page."""
confoverrides = {"html_theme_options.navbar_align": "right"}
sphinx_build = sphinx_build_factory("base", confoverrides=confoverrides).build()
@@ -347,7 +294,8 @@ def test_navbar_align_right(sphinx_build_factory):
)
-def test_navbar_no_in_page_headers(sphinx_build_factory, file_regression):
+def test_navbar_no_in_page_headers(sphinx_build_factory, file_regression) -> None:
+ """Test navbar elements did not change (regression test)."""
# https://github.com/pydata/pydata-sphinx-theme/issues/302
sphinx_build = sphinx_build_factory("test_navbar_no_in_page_headers").build()
@@ -357,7 +305,7 @@ def test_navbar_no_in_page_headers(sphinx_build_factory, file_regression):
@pytest.mark.parametrize("n_links", (0, 4, 8)) # 0 = only dropdown, 8 = no dropdown
-def test_navbar_header_dropdown(sphinx_build_factory, file_regression, n_links):
+def test_navbar_header_dropdown(sphinx_build_factory, n_links) -> None:
"""Test whether dropdown appears based on number of header links + config."""
extra_links = [{"url": f"https://{ii}.org", "name": ii} for ii in range(3)]
@@ -374,20 +322,21 @@ def test_navbar_header_dropdown(sphinx_build_factory, file_regression, n_links):
# There should be *only* a dropdown and no standalone links
assert navbar.select("div.dropdown") and not navbar.select(
".navbar-nav > li.nav-item"
- ) # noqa
+ )
if n_links == 4:
# There should be at least one standalone link, and a dropdown
assert navbar.select(".navbar-nav > li.nav-item") and navbar.select(
"div.dropdown"
- ) # noqa
+ )
if n_links == 8:
# There should be no dropdown and only standalone links
assert navbar.select(".navbar-nav > li.nav-item") and not navbar.select(
"div.dropdown"
- ) # noqa
+ )
-def test_sidebars_captions(sphinx_build_factory, file_regression):
+def test_sidebars_captions(sphinx_build_factory, file_regression) -> None:
+ """Test that the captions are rendered."""
sphinx_build = sphinx_build_factory("sidebars").build()
subindex_html = sphinx_build.html_tree("section1/index.html")
@@ -397,7 +346,8 @@ def test_sidebars_captions(sphinx_build_factory, file_regression):
file_regression.check(sidebar.prettify(), extension=".html")
-def test_sidebars_nested_page(sphinx_build_factory, file_regression):
+def test_sidebars_nested_page(sphinx_build_factory, file_regression) -> None:
+ """Test that nested pages are shown in the sidebar."""
sphinx_build = sphinx_build_factory("sidebars").build()
subindex_html = sphinx_build.html_tree("section1/subsection1/page1.html")
@@ -407,8 +357,8 @@ def test_sidebars_nested_page(sphinx_build_factory, file_regression):
file_regression.check(sidebar.prettify(), extension=".html")
-def test_sidebars_level2(sphinx_build_factory, file_regression):
- """Sidebars in a second-level page w/ children"""
+def test_sidebars_level2(sphinx_build_factory, file_regression) -> None:
+ """Test sidebars in a second-level page w/ children."""
confoverrides = {"templates_path": ["_templates_sidebar_level2"]}
sphinx_build = sphinx_build_factory("sidebars", confoverrides=confoverrides).build()
@@ -419,9 +369,9 @@ def test_sidebars_level2(sphinx_build_factory, file_regression):
file_regression.check(sidebar.prettify(), extension=".html")
-def test_sidebars_show_nav_level0(sphinx_build_factory, file_regression):
- """
- Regression test for show_nav_level:0 when the toc is divided into parts.
+def test_sidebars_show_nav_level0(sphinx_build_factory) -> None:
+ """Regression test for show_nav_level:0 when the toc is divided into parts.
+
Testing both home page and a subsection page for correct elements.
"""
confoverrides = {"html_theme_options.show_nav_level": 0}
@@ -459,10 +409,8 @@ def test_sidebars_show_nav_level0(sphinx_build_factory, file_regression):
assert "checked" in ii.attrs
-def test_included_toc(sphinx_build_factory):
- """Test that Sphinx project containing TOC (.. toctree::) included
- via .. include:: can be successfully built.
- """
+def test_included_toc(sphinx_build_factory) -> None:
+ """Test that Sphinx project containing TOC (.. toctree::) included via .. include:: can be successfully built."""
# Regression test for bug resolved in #347.
# Tests mainly makes sure that the sphinx_build.build() does not raise exception.
# https://github.com/pydata/pydata-sphinx-theme/pull/347
@@ -602,7 +550,8 @@ def test_included_toc(sphinx_build_factory):
@pytest.mark.parametrize("html_context,edit_text_and_url", all_edits)
-def test_edit_page_url(sphinx_build_factory, html_context, edit_text_and_url):
+def test_edit_page_url(sphinx_build_factory, html_context, edit_text_and_url) -> None:
+ """Test the edit this page generated link."""
confoverrides = {
"html_theme_options.use_edit_page_button": True,
"html_context": html_context,
@@ -649,7 +598,8 @@ def test_edit_page_url(sphinx_build_factory, html_context, edit_text_and_url):
),
],
)
-def test_analytics(sphinx_build_factory, provider, tags):
+def test_analytics(sphinx_build_factory, provider, tags) -> None:
+ """Check the Google analytics."""
confoverrides = provider
sphinx_build = sphinx_build_factory("base", confoverrides=confoverrides)
sphinx_build.build()
@@ -663,7 +613,8 @@ def test_analytics(sphinx_build_factory, provider, tags):
assert tags_found is True
-def test_plausible(sphinx_build_factory):
+def test_plausible(sphinx_build_factory) -> None:
+ """Test the Plausible analytics."""
provider = {
"html_theme_options.analytics": {
"plausible_analytics_domain": "toto",
@@ -683,7 +634,7 @@ def test_plausible(sphinx_build_factory):
assert attr_found is True
-def test_show_nav_level(sphinx_build_factory):
+def test_show_nav_level(sphinx_build_factory) -> None:
"""The navbar items align with the proper part of the page."""
confoverrides = {"html_theme_options.show_nav_level": 2}
sphinx_build = sphinx_build_factory("sidebars", confoverrides=confoverrides).build()
@@ -700,7 +651,7 @@ def test_show_nav_level(sphinx_build_factory):
@pytest.mark.parametrize("url", switcher_files)
-def test_version_switcher(sphinx_build_factory, file_regression, url):
+def test_version_switcher(sphinx_build_factory, file_regression, url) -> None:
"""Regression test the version switcher dropdown HTML.
Note that a lot of the switcher HTML gets populated by JavaScript,
@@ -731,17 +682,16 @@ def test_version_switcher(sphinx_build_factory, file_regression, url):
)
elif url == "http://a.b/switcher.json": # this file doesn't exist"
- not_read = 'WARNING: The version switcher "http://a.b/switcher.json" file cannot be read due to the following error:\n' # noqa
+ not_read = 'WARNING: The version switcher "http://a.b/switcher.json" file cannot be read due to the following error:\n'
assert not_read in escape_ansi(sphinx_build.warnings).strip()
elif url == "missing_url.json": # this file is missing the url key for one version
- missing_url = 'WARNING: The version switcher "missing_url.json" file is malformed at least one of the items is missing the "url" or "version" key' # noqa
+ missing_url = 'WARNING: The version switcher "missing_url.json" file is malformed at least one of the items is missing the "url" or "version" key'
assert escape_ansi(sphinx_build.warnings).strip() == missing_url
-def test_theme_switcher(sphinx_build_factory, file_regression):
- """Regression test the theme switcher btn HTML"""
-
+def test_theme_switcher(sphinx_build_factory, file_regression) -> None:
+ """Regression test for the theme switcher button."""
sphinx_build = sphinx_build_factory("base").build()
switcher = (
sphinx_build.html_tree("index.html")
@@ -753,9 +703,8 @@ def test_theme_switcher(sphinx_build_factory, file_regression):
)
-def test_shorten_link(sphinx_build_factory, file_regression):
- """regression test the shorten links html"""
-
+def test_shorten_link(sphinx_build_factory, file_regression) -> None:
+ """Regression test for "edit on " link shortening."""
sphinx_build = sphinx_build_factory("base").build()
github = sphinx_build.html_tree("page1.html").select(".github-container")[0]
@@ -765,9 +714,8 @@ def test_shorten_link(sphinx_build_factory, file_regression):
file_regression.check(gitlab.prettify(), basename="gitlab_links", extension=".html")
-def test_math_header_item(sphinx_build_factory, file_regression):
- """regression test the math items in a header title"""
-
+def test_math_header_item(sphinx_build_factory, file_regression) -> None:
+ """Regression test for math items in a header title."""
sphinx_build = sphinx_build_factory("base").build()
li = sphinx_build.html_tree("page2.html").select(".bd-navbar-elements li")[1]
file_regression.check(li.prettify(), basename="math_header_item", extension=".html")
@@ -783,7 +731,7 @@ def test_math_header_item(sphinx_build_factory, file_regression):
),
],
)
-def test_pygments_fallbacks(sphinx_build_factory, style_names, keyword_colors):
+def test_pygments_fallbacks(sphinx_build_factory, style_names, keyword_colors) -> None:
"""Test that setting color themes works.
NOTE: the expected keyword colors for fake_foo and fake_bar are the colors
@@ -825,10 +773,9 @@ def test_pygments_fallbacks(sphinx_build_factory, style_names, keyword_colors):
assert sum(matches) == 1
-def test_deprecated_build_html(sphinx_build_factory, file_regression):
- """Test building the base html template with all the deprecated configs"""
-
- sphinx_build = sphinx_build_factory("deprecated") # type: SphinxBuild
+def test_deprecated_build_html(sphinx_build_factory, file_regression) -> None:
+ """Test building the base html template with all the deprecated configs."""
+ sphinx_build = sphinx_build_factory("deprecated")
# Basic build with defaults
sphinx_build.build(no_warning=False)
@@ -871,7 +818,7 @@ def test_deprecated_build_html(sphinx_build_factory, file_regression):
assert not sphinx_build.html_tree("page2.html").select("div.bd-sidebar-secondary")
-def test_empty_templates(sphinx_build_factory):
+def test_empty_templates(sphinx_build_factory) -> None:
"""If a template is empty (e.g., via a config), it should be removed."""
# When configured to be gone, the template should be removed w/ its parent.
# ABlog needs to be added so we can test that template rendering works w/ it.
@@ -888,15 +835,15 @@ def test_empty_templates(sphinx_build_factory):
assert not html.select(".navbar-icon-links")
-def test_translations(sphinx_build_factory):
+def test_translations(sphinx_build_factory) -> None:
"""Test that basic translation functionality works.
This will build our test site with the French language, and test
that a few phrases are in French.
We use this test to catch regressions if we change wording without
- changing the translation files."""
-
+ changing the translation files.
+ """
confoverrides = {
"language": "fr",
"html_context": {