From 369c0ee9da8c39ae87c3fb546621f1d810c1d0c1 Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Mon, 27 Mar 2023 22:30:12 +0200 Subject: [PATCH 01/13] sanitize babel calls --- .pre-commit-config.yaml | 20 +-- docs/conf.py | 31 ++++- docs/examples/test_py_module/test.py | 3 - docs/scripts/gallery_directive/__init__.py | 6 +- .../scripts/generate_collaborators_gallery.py | 13 +- docs/scripts/generate_gallery_images.py | 17 ++- docs/scripts/update_kitchen_sink.py | 8 +- noxfile.py | 118 +++++++++++------- pyproject.toml | 15 +++ src/pydata_sphinx_theme/__init__.py | 95 ++++++-------- .../static/scripts/bootstrap.js.LICENSE.txt | 5 + src/pydata_sphinx_theme/translator.py | 21 ++-- tests/check_warnings.py | 5 +- tests/test_build.py | 45 ++++--- 14 files changed, 215 insertions(+), 187 deletions(-) create mode 100644 src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/scripts/bootstrap.js.LICENSE.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 466efae7c..22b17dec3 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 - 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 + #- repo: https://github.com/charliermarsh/ruff-pre-commit + # rev: "v0.0.215" + # hooks: + # - id: ruff - repo: https://github.com/asottile/pyupgrade rev: v3.3.1 diff --git a/docs/conf.py b/docs/conf.py index 162d8f005..08b19ceff 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,17 @@ +"""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 typing import Any, Dict + import pydata_sphinx_theme +from sphinx.application import Sphinx sys.path.append("scripts") from gallery_directive import GalleryDirective @@ -57,7 +67,6 @@ autosummary_generate = True - # -- Internationalization ---------------------------------------------------- # specifying the natural language populates some key tags @@ -223,9 +232,11 @@ # -- application setup ------------------------------------------------------- -def setup_to_main(app, pagename, templatename, context, doctree): +def setup_to_main( + app: Sphinx, pagename: str, templatename: str, context, doctree +) -> None: 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 +251,18 @@ def to_main(link: str) -> str: context["to_main"] = to_main -def setup(app): +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.add_directive("gallery-grid", GalleryDirective) 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/test.py b/docs/examples/test_py_module/test.py index c76f978d0..52286000b 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,7 +74,6 @@ def capitalize(self, myvalue): :rtype: string """ - return myvalue.upper() def another_function(self, a, b, **kwargs): diff --git a/docs/scripts/gallery_directive/__init__.py b/docs/scripts/gallery_directive/__init__.py index 2119d9872..8c126c131 100644 --- a/docs/scripts/gallery_directive/__init__.py +++ b/docs/scripts/gallery_directive/__init__.py @@ -8,14 +8,14 @@ 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 List from docutils import nodes from docutils.parsers.rst import directives -from sphinx.util.docutils import SphinxDirective from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective +from yaml import safe_load logger = logging.getLogger(__name__) 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 9be7dd10b..cbd4f9e13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,21 @@ ignore = ["D001"] # we follow a 1 line = 1 paragraph style 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" extension = "html" diff --git a/src/pydata_sphinx_theme/__init__.py b/src/pydata_sphinx_theme/__init__.py index 3d493a91d..64a802d51 100644 --- a/src/pydata_sphinx_theme/__init__.py +++ b/src/pydata_sphinx_theme/__init__.py @@ -1,29 +1,28 @@ -""" -Bootstrap-based sphinx theme from the PyData community -""" +"""Bootstrap-based sphinx theme from the PyData community.""" + +import json import os -from pathlib import Path +import types from functools import lru_cache -import json +from pathlib import Path from urllib.parse import urlparse, urlunparse -import types import jinja2 +import requests from bs4 import BeautifulSoup as bs from docutils import nodes +from pygments.formatters import HtmlFormatter +from pygments.styles import get_all_styles +from requests.exceptions import ConnectionError, HTTPError, RetryError from sphinx import addnodes +from sphinx.addnodes import toctree as toctree_node 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.transforms.post_transforms import SphinxPostTransform +from sphinx.util import isurl, logging 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.util.nodes import NodeMatcher from .translator import BootstrapHTML5TranslatorMixin @@ -315,13 +314,13 @@ def _remove_empty_templates(tname): 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()}\)" "" ) @@ -332,8 +331,8 @@ def add_toctree_functions(app, pagename, templatename, context, doctree): @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. + """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. @@ -352,7 +351,6 @@ def generate_header_nav_html(n_links_before_dropdown=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: @@ -442,7 +440,7 @@ def generate_header_nav_html(n_links_before_dropdown=5): {links_dropdown_html} - """ # noqa + """ return out @@ -450,10 +448,11 @@ def generate_header_nav_html(n_links_before_dropdown=5): # 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). + """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 @@ -480,7 +479,7 @@ def generate_toctree_html(kind, startdepth=1, show_nav_level=1, **kwargs): kwargs: passed to the Sphinx `toctree` template function. - Returns + Returns: ------- HTML string (if kind == "sidebar") OR BeautifulSoup object (if kind == "raw") @@ -544,7 +543,6 @@ def generate_toctree_html(kind, startdepth=1, show_nav_level=1, **kwargs): @lru_cache(maxsize=None) def generate_toc_html(kind="html"): """Return the within-page TOC links in HTML.""" - if "toc" not in context: return "" @@ -708,9 +706,7 @@ def _get_local_toctree_for( 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. + """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. @@ -745,8 +741,7 @@ def index_toctree(app, pagename: str, startdepth: int, collapse: bool = True, ** def soup_to_python(soup, only_pages=False): - """ - Convert the toctree html structure to python objects which can be used in Jinja. + """Convert the toctree html structure to python objects which can be used in Jinja. Parameters ---------- @@ -755,7 +750,7 @@ def soup_to_python(soup, only_pages=False): Only include items for full pages in the output dictionary. Exclude anchor links (TOC items with a URL that starts with #) - Returns + Returns: ------- nav : list of dicts The toctree, converted into a dictionary with key/values that work @@ -886,10 +881,7 @@ def get_edit_provider_and_url(): def _get_styles(formatter, prefix): - """ - Get styles out of a formatter, where everything has the correct 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) @@ -897,9 +889,9 @@ def _get_styles(formatter, 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 + """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) @@ -916,8 +908,7 @@ def get_pygments_stylesheet(light_style, dark_style): def _overwrite_pygments_css(app, exception=None): - """ - Overwrite pygments.css to allow dynamic light/dark switching. + """Overwrite pygments.css to allow dynamic light/dark switching. Sphinx natively supports config variables `pygments_style` and `pygments_dark_style`. However, quoting from @@ -981,8 +972,9 @@ def _overwrite_pygments_css(app, exception=None): 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 + for numpydoc is docutils>=0.18.1. """ return ( node.findall(condition, **kwargs) @@ -992,9 +984,8 @@ def _traverse_or_findall(node, 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. + """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 @@ -1003,7 +994,7 @@ class ShortenLinkTransform(SphinxPostTransform): 2i2c-org/infrastructure#1329 - """ # noqa + """ default_priority = 400 formats = ("html",) @@ -1027,9 +1018,7 @@ def run(self, **kwargs): 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 - """ + """Parse the content of the url with respect to the selected platform.""" path = uri.path if path == "": @@ -1083,8 +1072,7 @@ def parse_url(self, uri): def setup_translators(app): - """ - Add bootstrap HTML functionality if we are using an HTML translator. + """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 @@ -1137,7 +1125,6 @@ def setup_logo_path( 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") @@ -1166,10 +1153,7 @@ def setup_logo_path( 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 - """ + """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.""" theme_options = _get_theme_options(app) logo = theme_options.get("logo", {}) staticdir = Path(app.builder.outdir) / "_static" @@ -1196,6 +1180,7 @@ def copy_logo_images(app: Sphinx, exception=None) -> None: def setup(app): + """setup the Sphinx application.""" here = Path(__file__).parent.resolve() theme_path = here / "theme" / "pydata_sphinx_theme" diff --git a/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/scripts/bootstrap.js.LICENSE.txt b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/scripts/bootstrap.js.LICENSE.txt new file mode 100644 index 000000000..91ad10aa0 --- /dev/null +++ b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/scripts/bootstrap.js.LICENSE.txt @@ -0,0 +1,5 @@ +/*! + * Bootstrap v5.2.3 (https://getbootstrap.com/) + * Copyright 2011-2022 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ diff --git a/src/pydata_sphinx_theme/translator.py b/src/pydata_sphinx_theme/translator.py index 3c9ef46a9..54bce07d2 100644 --- a/src/pydata_sphinx_theme/translator.py +++ b/src/pydata_sphinx_theme/translator.py @@ -1,18 +1,16 @@ -""" -A custom Sphinx HTML Translator for Bootstrap layout -""" -from packaging.version import Version +"""A custom Sphinx HTML Translator for Bootstrap layout.""" import sphinx -from sphinx.util import logging +from packaging.version import Version 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 +20,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 = {} diff --git a/tests/check_warnings.py b/tests/check_warnings.py index 1b08eb5b7..91627e804 100644 --- a/tests/check_warnings.py +++ b/tests/check_warnings.py @@ -1,5 +1,5 @@ -from pathlib import Path import sys +from pathlib import Path from colorama import Fore, init @@ -10,7 +10,7 @@ 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 + 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 @@ -20,7 +20,6 @@ def check_warnings(file): 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/test_build.py b/tests/test_build.py index 5706dbbc7..b57a5dad2 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -13,7 +13,7 @@ def escape_ansi(string): - """helper function to remove ansi coloring from sphinx warnings""" + """helper function to remove ansi coloring from sphinx warnings.""" ansi_escape = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]") return ansi_escape.sub("", string) @@ -62,7 +62,6 @@ def _func(src_folder, **kwargs): def test_build_html(sphinx_build_factory, file_regression): """Test building the base html template and config.""" - sphinx_build = sphinx_build_factory("base") # type: SphinxBuild # Basic build with defaults @@ -204,7 +203,8 @@ def test_logo_two_images(sphinx_build_factory): 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.""" + when no default mode is set. + """ # Ensure no default mode is set confoverrides = { "html_context": {}, @@ -219,7 +219,8 @@ def test_primary_logo_is_light_when_no_default_mode(sphinx_build_factory): 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.""" + when default mode is explicitly set to auto. + """ # Ensure no default mode is set confoverrides = { "html_context": {"default_mode": "auto"}, @@ -234,7 +235,8 @@ def test_primary_logo_is_light_when_default_mode_is_set_to_auto(sphinx_build_fac 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.""" + when default mode is set to light. + """ # Ensure no default mode is set confoverrides = { "html_context": {"default_mode": "light"}, @@ -249,7 +251,8 @@ def test_primary_logo_is_light_when_default_mode_is_light(sphinx_build_factory): 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.""" + when default mode is set to dark. + """ # Ensure no default mode is set confoverrides = { "html_context": {"default_mode": "dark"}, @@ -313,7 +316,7 @@ def test_logo_external_image(sphinx_build_factory): def test_logo_template_rejected(sphinx_build_factory): - """Test that dynamic Sphinx templates are not accepted as logo files""" + """Test that dynamic Sphinx templates are not accepted as logo files.""" # Test with a specified external logo image source confoverrides = { "html_theme_options": { @@ -374,17 +377,17 @@ 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): @@ -408,7 +411,7 @@ def test_sidebars_nested_page(sphinx_build_factory, file_regression): def test_sidebars_level2(sphinx_build_factory, file_regression): - """Sidebars in a second-level page w/ children""" + """Sidebars in a second-level page w/ children.""" confoverrides = {"templates_path": ["_templates_sidebar_level2"]} sphinx_build = sphinx_build_factory("sidebars", confoverrides=confoverrides).build() @@ -731,17 +734,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""" - + """Regression test the theme switcher btn HTML.""" sphinx_build = sphinx_build_factory("base").build() switcher = ( sphinx_build.html_tree("index.html") @@ -754,8 +756,7 @@ def test_theme_switcher(sphinx_build_factory, file_regression): def test_shorten_link(sphinx_build_factory, file_regression): - """regression test the shorten links html""" - + """regression test the shorten links html.""" sphinx_build = sphinx_build_factory("base").build() github = sphinx_build.html_tree("page1.html").select(".github-container")[0] @@ -766,8 +767,7 @@ def test_shorten_link(sphinx_build_factory, file_regression): def test_math_header_item(sphinx_build_factory, file_regression): - """regression test the math items in a header title""" - + """regression test the 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") @@ -826,8 +826,7 @@ def test_pygments_fallbacks(sphinx_build_factory, style_names, keyword_colors): def test_deprecated_build_html(sphinx_build_factory, file_regression): - """Test building the base html template with all the deprecated configs""" - + """Test building the base html template with all the deprecated configs.""" sphinx_build = sphinx_build_factory("deprecated") # type: SphinxBuild # Basic build with defaults @@ -895,8 +894,8 @@ def test_translations(sphinx_build_factory): 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": { From 5d12c59fa207912b90eed08d2454aa7fd92c8014 Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Mon, 27 Mar 2023 22:40:47 +0200 Subject: [PATCH 02/13] move the gallery directive to an extention folder --- .../gallery_directive.py} | 19 ++++++++++++++++++- docs/conf.py | 7 ++++--- 2 files changed, 22 insertions(+), 4 deletions(-) rename docs/{scripts/gallery_directive/__init__.py => _extention/gallery_directive.py} (91%) diff --git a/docs/scripts/gallery_directive/__init__.py b/docs/_extention/gallery_directive.py similarity index 91% rename from docs/scripts/gallery_directive/__init__.py rename to docs/_extention/gallery_directive.py index 8c126c131..f9ebc42f5 100644 --- a/docs/scripts/gallery_directive/__init__.py +++ b/docs/_extention/gallery_directive.py @@ -9,12 +9,13 @@ but might be abstracted into a standalone package if it proves useful. """ from pathlib import Path -from typing import List +from typing import List, Dict, Any from docutils import nodes from docutils.parsers.rst import directives from sphinx.util import logging from sphinx.util.docutils import SphinxDirective +from sphinx.application import Sphinx 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 parralel 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 08b19ceff..00de061be 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,12 +9,12 @@ import os import sys from typing import Any, Dict +from pathlib import Path 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 ----------------------------------------------------- @@ -32,6 +32,7 @@ "sphinxext.rediraffe", "sphinx_design", "sphinx_copybutton", + "_extention.gallery_directive", # For extension examples and demos "ablog", "jupyter_sphinx", @@ -259,7 +260,7 @@ def setup(app: Sphinx) -> Dict[str, Any]: Returns: the 2 parralel parameters set to ``True``. """ - app.add_directive("gallery-grid", GalleryDirective) + app.connect("html-page-context", setup_to_main) return { From 8a87cbaa523f41d6cd3ec107b1b53e132536e18f Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Mon, 27 Mar 2023 23:20:57 +0200 Subject: [PATCH 03/13] clean test folder --- .pre-commit-config.yaml | 8 +- docs/_extention/gallery_directive.py | 4 +- docs/conf.py | 5 +- docs/examples/test_py_module/__init__.py | 1 + docs/examples/test_py_module/test.py | 3 +- src/pydata_sphinx_theme/__init__.py | 3 +- tests/check_warnings.py | 16 +- tests/conftest.py | 77 ++++++++ tests/sites/base/conf.py | 2 + tests/sites/deprecated/conf.py | 2 + tests/sites/sidebars/conf.py | 2 + tests/sites/test_included_toc/conf.py | 2 + .../test_navbar_no_in_page_headers/conf.py | 2 + tests/test_build.py | 174 ++++++------------ 14 files changed, 168 insertions(+), 133 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22b17dec3..1b5e3aa41 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,10 +17,10 @@ repos: hooks: - id: black - #- repo: https://github.com/charliermarsh/ruff-pre-commit - # rev: "v0.0.215" - # hooks: - # - id: ruff + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: "v0.0.215" + hooks: + - id: ruff - repo: https://github.com/asottile/pyupgrade rev: v3.3.1 diff --git a/docs/_extention/gallery_directive.py b/docs/_extention/gallery_directive.py index f9ebc42f5..f3b6d82fa 100644 --- a/docs/_extention/gallery_directive.py +++ b/docs/_extention/gallery_directive.py @@ -9,13 +9,13 @@ but might be abstracted into a standalone package if it proves useful. """ from pathlib import Path -from typing import List, Dict, Any +from typing import Any, Dict, List from docutils import nodes from docutils.parsers.rst import directives +from sphinx.application import Sphinx from sphinx.util import logging from sphinx.util.docutils import SphinxDirective -from sphinx.application import Sphinx from yaml import safe_load logger = logging.getLogger(__name__) diff --git a/docs/conf.py b/docs/conf.py index 00de061be..85afc1633 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,8 +8,8 @@ # -- Path setup -------------------------------------------------------------- import os import sys -from typing import Any, Dict from pathlib import Path +from typing import Any, Dict import pydata_sphinx_theme from sphinx.application import Sphinx @@ -236,6 +236,8 @@ def setup_to_main( app: Sphinx, pagename: str, templatename: str, context, doctree ) -> None: + """Wrapping setup method.""" + def to_main(link: str) -> str: """Transform "edit on github" links and make sure they always point to the main branch. @@ -260,7 +262,6 @@ def setup(app: Sphinx) -> Dict[str, Any]: Returns: the 2 parralel parameters set to ``True``. """ - app.connect("html-page-context", setup_to_main) return { 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 52286000b..800930749 100644 --- a/docs/examples/test_py_module/test.py +++ b/docs/examples/test_py_module/test.py @@ -77,8 +77,7 @@ def capitalize(self, myvalue): 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/src/pydata_sphinx_theme/__init__.py b/src/pydata_sphinx_theme/__init__.py index 64a802d51..df0a0ab0c 100644 --- a/src/pydata_sphinx_theme/__init__.py +++ b/src/pydata_sphinx_theme/__init__.py @@ -1002,6 +1002,7 @@ class ShortenLinkTransform(SphinxPostTransform): 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): @@ -1180,7 +1181,7 @@ def copy_logo_images(app: Sphinx, exception=None) -> None: def setup(app): - """setup the Sphinx application.""" + """Setup the Sphinx application.""" here = Path(__file__).parent.resolve() theme_path = here / "theme" / "pydata_sphinx_theme" diff --git a/tests/check_warnings.py b/tests/check_warnings.py index 91627e804..241f4acf1 100644 --- a/tests/check_warnings.py +++ b/tests/check_warnings.py @@ -1,3 +1,5 @@ +"""Check the list of warnings produced by a doc build.""" + import sys from pathlib import Path @@ -7,16 +9,16 @@ 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 """ diff --git a/tests/conftest.py b/tests/conftest.py index 970578a0e..0dd9a73cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,78 @@ +"""Configuration of the pytest session.""" + +import re +from pathlib import Path +from shutil import copytree +from typing import Callable, Self + +import pytest +from bs4 import BeautifulSoup +from sphinx.testing.path import path as sphinx_path +from sphinx.testing.util import SphinxTestApp + 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 b57a5dad2..0b645fd3e 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1,68 +1,17 @@ -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.""" - 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 +from .conftest import escape_ansi - @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() @@ -88,8 +37,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, } @@ -101,7 +50,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": [ { @@ -161,7 +111,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() @@ -172,7 +122,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() @@ -181,7 +131,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 = { @@ -200,11 +150,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,11 +163,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 whzn default is set to auto.""" # Ensure no default mode is set confoverrides = { "html_context": {"default_mode": "auto"}, @@ -232,11 +178,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 ligh.""" # Ensure no default mode is set confoverrides = { "html_context": {"default_mode": "light"}, @@ -248,11 +191,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"}, @@ -264,7 +204,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 = { @@ -281,7 +221,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" @@ -298,7 +238,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" @@ -315,7 +255,7 @@ def test_logo_external_image(sphinx_build_factory): assert f'src="{test_url}"' in index_str -def test_logo_template_rejected(sphinx_build_factory): +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 = { @@ -329,14 +269,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() @@ -350,7 +290,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 No are in page headers.""" # https://github.com/pydata/pydata-sphinx-theme/issues/302 sphinx_build = sphinx_build_factory("test_navbar_no_in_page_headers").build() @@ -360,7 +301,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)] @@ -390,7 +331,8 @@ def test_navbar_header_dropdown(sphinx_build_factory, file_regression, n_links): ) -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") @@ -400,7 +342,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") @@ -410,7 +353,7 @@ 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): +def test_sidebars_level2(sphinx_build_factory, file_regression) -> None: """Sidebars in a second-level page w/ children.""" confoverrides = {"templates_path": ["_templates_sidebar_level2"]} sphinx_build = sphinx_build_factory("sidebars", confoverrides=confoverrides).build() @@ -422,9 +365,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} @@ -462,10 +405,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 @@ -605,7 +546,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, @@ -652,7 +594,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() @@ -666,7 +609,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", @@ -686,7 +630,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() @@ -703,7 +647,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, @@ -742,7 +686,7 @@ def test_version_switcher(sphinx_build_factory, file_regression, url): assert escape_ansi(sphinx_build.warnings).strip() == missing_url -def test_theme_switcher(sphinx_build_factory, file_regression): +def test_theme_switcher(sphinx_build_factory, file_regression) -> None: """Regression test the theme switcher btn HTML.""" sphinx_build = sphinx_build_factory("base").build() switcher = ( @@ -755,8 +699,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 the shorten links html.""" sphinx_build = sphinx_build_factory("base").build() github = sphinx_build.html_tree("page1.html").select(".github-container")[0] @@ -766,8 +710,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 the 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 +727,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,9 +769,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): +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") # type: SphinxBuild + sphinx_build = sphinx_build_factory("deprecated") # Basic build with defaults sphinx_build.build(no_warning=False) @@ -870,7 +814,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. @@ -887,7 +831,7 @@ 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 From 7ce98e550d7992255652adb3ec3a407b6e11f0cb Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Tue, 28 Mar 2023 00:39:49 +0200 Subject: [PATCH 04/13] split __init__.py --- docs/conf.py | 2 +- pyproject.toml | 3 +- src/pydata_sphinx_theme/__init__.py | 929 +--------------------- src/pydata_sphinx_theme/edit_this_page.py | 81 ++ src/pydata_sphinx_theme/logo.py | 82 ++ src/pydata_sphinx_theme/pygment.py | 100 +++ src/pydata_sphinx_theme/short_link.py | 111 +++ src/pydata_sphinx_theme/toctree.py | 433 ++++++++++ src/pydata_sphinx_theme/translator.py | 38 + src/pydata_sphinx_theme/utils.py | 76 ++ tests/conftest.py | 3 +- tests/test_build.py | 6 +- 12 files changed, 949 insertions(+), 915 deletions(-) create mode 100644 src/pydata_sphinx_theme/edit_this_page.py create mode 100644 src/pydata_sphinx_theme/logo.py create mode 100644 src/pydata_sphinx_theme/pygment.py create mode 100644 src/pydata_sphinx_theme/short_link.py create mode 100644 src/pydata_sphinx_theme/toctree.py create mode 100644 src/pydata_sphinx_theme/utils.py diff --git a/docs/conf.py b/docs/conf.py index 85afc1633..0bbbb5bab 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -236,7 +236,7 @@ def setup_to_main( app: Sphinx, pagename: str, templatename: str, context, doctree ) -> None: - """Wrapping setup method.""" + """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. diff --git a/pyproject.toml b/pyproject.toml index cbd4f9e13..a24f43e1f 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" } diff --git a/src/pydata_sphinx_theme/__init__.py b/src/pydata_sphinx_theme/__init__.py index df0a0ab0c..a7c7b3ce8 100644 --- a/src/pydata_sphinx_theme/__init__.py +++ b/src/pydata_sphinx_theme/__init__.py @@ -2,63 +2,36 @@ import json import os -import types -from functools import lru_cache from pathlib import Path -from urllib.parse import urlparse, urlunparse +from typing import Dict +from urllib.parse import urlparse -import jinja2 import requests -from bs4 import BeautifulSoup as bs -from docutils import nodes -from pygments.formatters import HtmlFormatter -from pygments.styles import get_all_styles from requests.exceptions import ConnectionError, HTTPError, RetryError -from sphinx import addnodes -from sphinx.addnodes import toctree as toctree_node from sphinx.application import Sphinx -from sphinx.environment.adapters.toctree import TocTree from sphinx.errors import ExtensionError -from sphinx.transforms.post_transforms import SphinxPostTransform -from sphinx.util import isurl, logging -from sphinx.util.fileutil import copy_asset_file -from sphinx.util.nodes import NodeMatcher +from sphinx.util import logging -from .translator import BootstrapHTML5TranslatorMixin +from .edit_this_page import setup_edit_url +from .logo import copy_logo_images, setup_logo_path +from .pygment import overwrite_pygments_css +from .short_link import ShortenLinkTransform +from .toctree import add_toctree_functions +from .translator import setup_translators +from .utils import config_provided_by_user, get_theme_options __version__ = "0.13.2dev0" logger = logging.getLogger(__name__) -def _get_theme_options(app): - """Return theme options for the application w/ a fallback if they don't exist. - - In general we want to modify app.builder.theme_options if it exists, so prefer that first. - """ - if hasattr(app.builder, "theme_options"): - # In most HTML build cases this will exist except for some circumstances (see below). - return app.builder.theme_options - elif hasattr(app.config, "html_theme_options"): - # For example, linkcheck will have this configured but won't be in builder obj. - return app.config.html_theme_options - else: - # Empty dictionary as a fail-safe. - 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(app) + theme_options = get_theme_options(app) # TODO: deprecation; remove after 0.14 release if theme_options.get("logo_text"): @@ -101,7 +74,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 config_provided_by_user(app, "html_permalinks_icon"): app.config.html_permalinks_icon = "#" # Raise a warning for a deprecated theme switcher config @@ -188,7 +161,7 @@ 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( + if "ablog" in app.config.extensions and not config_provided_by_user( app, "fontawesome_included" ): app.config.fontawesome_included = True @@ -227,7 +200,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 = [ @@ -312,875 +287,7 @@ 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""" - - """ - - 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, if not, use the - # one we set in theme.conf - style_key = f"pygment_{light_or_dark}_style" - - # globalcontext sometimes doesn't exist so this ensures we do not error - theme_name = _get_theme_options(app).get(style_key, None) - if theme_name is None and hasattr(app.builder, "globalcontext"): - theme_name = app.builder.globalcontext.get(f"theme_{style_key}") - - # make sure we can load the style - if theme_name not in pygments_styles: - logger.warning( - f"Color theme {theme_name} not found by pygments, falling back to {fallback}." - ) - theme_name = fallback - # assign to the appropriate variable - if light_or_dark == "light": - light_theme = theme_name - else: - dark_theme = theme_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 - - """ - - 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): - """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.""" - theme_options = _get_theme_options(app) - logo = theme_options.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" @@ -1195,7 +302,7 @@ def setup(app): app.connect("html-page-context", 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", overwrite_pygments_css) app.connect("build-finished", copy_logo_images) # https://www.sphinx-doc.org/en/master/extdev/i18n.html#extension-internationalization-i18n-and-localization-l10n-using-i18n-api 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..16e6a0af3 --- /dev/null +++ b/src/pydata_sphinx_theme/logo.py @@ -0,0 +1,82 @@ +"""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 + +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. + """ + theme_options = get_theme_options(app) + logo = theme_options.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..8e3c49581 --- /dev/null +++ b/src/pydata_sphinx_theme/pygment.py @@ -0,0 +1,100 @@ +"""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 + +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, if not, use the + # one we set in theme.conf + style_key = f"pygment_{light_or_dark}_style" + + # globalcontext sometimes doesn't exist so this ensures we do not error + theme_name = get_theme_options(app).get(style_key, None) + if theme_name is None and hasattr(app.builder, "globalcontext"): + theme_name = app.builder.globalcontext.get(f"theme_{style_key}") + + # make sure we can load the style + if theme_name not in pygments_styles: + logger.warning( + f"Color theme {theme_name} not found by pygments, falling back to {fallback}." + ) + theme_name = fallback + # assign to the appropriate variable + if light_or_dark == "light": + light_theme = theme_name + else: + dark_theme = theme_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..8c5460840 --- /dev/null +++ b/src/pydata_sphinx_theme/short_link.py @@ -0,0 +1,111 @@ +"""A custom Transform object to shorten github and gitlab links.""" + +from typing import Iterator +from urllib.parse import ParseResult, urlparse, urlunparse + +from docutils import nodes +from docutils.nodes import Node +from sphinx.transforms.post_transforms import SphinxPostTransform +from sphinx.util.nodes import NodeMatcher + + +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) + ) + + +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/toctree.py b/src/pydata_sphinx_theme/toctree.py new file mode 100644 index 000000000..7f4a36826 --- /dev/null +++ b/src/pydata_sphinx_theme/toctree.py @@ -0,0 +1,433 @@ +"""Custom inline math to include inline math in headers.""" + +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""" + + """ + + 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 54bce07d2..eb2046bd4 100644 --- a/src/pydata_sphinx_theme/translator.py +++ b/src/pydata_sphinx_theme/translator.py @@ -1,7 +1,10 @@ """A custom Sphinx HTML Translator for Bootstrap layout.""" +import types + import sphinx from packaging.version import Version +from sphinx.application import Sphinx from sphinx.ext.autosummary import autosummary_table from sphinx.util import logging @@ -55,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..5455a2d53 --- /dev/null +++ b/src/pydata_sphinx_theme/utils.py @@ -0,0 +1,76 @@ +"""General helpers for the management of config parameters.""" + +from typing import Any, Dict + +from bs4 import BeautifulSoup, ResultSet +from sphinx.application import Sphinx + + +def get_theme_options(app: Sphinx) -> Dict[str, Any]: + """Return theme options for the application w/ a fallback if they don't exist. + + In general we want to modify app.builder.theme_options if it exists, so prefer that first. + """ + if hasattr(app.builder, "theme_options"): + # In most HTML build cases this will exist except for some circumstances (see below). + return app.builder.theme_options + elif hasattr(app.config, "html_theme_options"): + # For example, linkcheck will have this configured but won't be in builder obj. + return app.config.html_theme_options + else: + # Empty dictionary as a fail-safe. + 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 diff --git a/tests/conftest.py b/tests/conftest.py index 0dd9a73cf..6fd8155f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,12 +3,13 @@ import re from pathlib import Path from shutil import copytree -from typing import Callable, Self +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" diff --git a/tests/test_build.py b/tests/test_build.py index 0b645fd3e..f0e1fe4f3 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -6,7 +6,11 @@ import pytest import sphinx.errors -from .conftest import escape_ansi + +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) def test_build_html(sphinx_build_factory, file_regression) -> None: From 8e442583d754a75413cfd897d064f7a25cf40880 Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Tue, 28 Mar 2023 00:47:36 +0200 Subject: [PATCH 05/13] add links between tests --- .github/workflows/tests.yml | 4 ++++ 1 file changed, 4 insertions(+) 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] From dd2c05a0fd59e0433dd6e319efd392ce40c7caf4 Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Tue, 28 Mar 2023 09:31:11 +0200 Subject: [PATCH 06/13] drop flake8 from pyproject.toml --- pyproject.toml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a24f43e1f..fb914e69a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,14 +96,6 @@ 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 From e95d9c3262b0861520cef08b817b93b877e9aa04 Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Tue, 28 Mar 2023 17:06:54 +0200 Subject: [PATCH 07/13] rename folder --- docs/{_extention => _extension}/gallery_directive.py | 0 docs/conf.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/{_extention => _extension}/gallery_directive.py (100%) diff --git a/docs/_extention/gallery_directive.py b/docs/_extension/gallery_directive.py similarity index 100% rename from docs/_extention/gallery_directive.py rename to docs/_extension/gallery_directive.py diff --git a/docs/conf.py b/docs/conf.py index 0bbbb5bab..f33cf1eb3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,7 +32,7 @@ "sphinxext.rediraffe", "sphinx_design", "sphinx_copybutton", - "_extention.gallery_directive", + "_extension.gallery_directive", # For extension examples and demos "ablog", "jupyter_sphinx", From 5122bc9faf62be6939bfd6126ab9cc97a0dc202e Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Tue, 28 Mar 2023 17:08:03 +0200 Subject: [PATCH 08/13] update docstring --- src/pydata_sphinx_theme/toctree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pydata_sphinx_theme/toctree.py b/src/pydata_sphinx_theme/toctree.py index 7f4a36826..a9d9c6796 100644 --- a/src/pydata_sphinx_theme/toctree.py +++ b/src/pydata_sphinx_theme/toctree.py @@ -1,4 +1,4 @@ -"""Custom inline math to include inline math in headers.""" +"""Methods to build the toctree used in the html pages.""" from functools import lru_cache from typing import List, Union From 71f73c25412afb63dcc3021635242f83062aee93 Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Wed, 29 Mar 2023 15:12:31 +0200 Subject: [PATCH 09/13] only import modules instead of functions --- src/pydata_sphinx_theme/__init__.py | 31 +++++++++++------------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/pydata_sphinx_theme/__init__.py b/src/pydata_sphinx_theme/__init__.py index a7c7b3ce8..f6a8b4d4c 100644 --- a/src/pydata_sphinx_theme/__init__.py +++ b/src/pydata_sphinx_theme/__init__.py @@ -12,13 +12,7 @@ from sphinx.errors import ExtensionError from sphinx.util import logging -from .edit_this_page import setup_edit_url -from .logo import copy_logo_images, setup_logo_path -from .pygment import overwrite_pygments_css -from .short_link import ShortenLinkTransform -from .toctree import add_toctree_functions -from .translator import setup_translators -from .utils import config_provided_by_user, get_theme_options +from . import edit_this_page, logo, pygment, short_link, toctree, translator, utils __version__ = "0.13.2dev0" @@ -31,7 +25,7 @@ def update_config(app): # 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(app) + theme_options = utils.get_theme_options(app) # TODO: deprecation; remove after 0.14 release if theme_options.get("logo_text"): @@ -74,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 @@ -161,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 @@ -294,16 +287,16 @@ def setup(app: Sphinx) -> Dict[str, str]: 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") From 55bcff961755a1f7f0af59af7a4153d99d08634c Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Wed, 29 Mar 2023 15:30:28 +0200 Subject: [PATCH 10/13] ignore bootstrap license --- .../theme/pydata_sphinx_theme/static/.gitignore | 1 + .../static/scripts/bootstrap.js.LICENSE.txt | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/scripts/bootstrap.js.LICENSE.txt 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/theme/pydata_sphinx_theme/static/scripts/bootstrap.js.LICENSE.txt b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/scripts/bootstrap.js.LICENSE.txt deleted file mode 100644 index 91ad10aa0..000000000 --- a/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/static/scripts/bootstrap.js.LICENSE.txt +++ /dev/null @@ -1,5 +0,0 @@ -/*! - * Bootstrap v5.2.3 (https://getbootstrap.com/) - * Copyright 2011-2022 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */ From f0b32058d001454a8bbd4b90f76572e14264db9c Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Wed, 29 Mar 2023 15:49:47 +0200 Subject: [PATCH 11/13] reintegrate modifications from #1264 --- src/pydata_sphinx_theme/__init__.py | 5 +++-- src/pydata_sphinx_theme/logo.py | 5 ++--- src/pydata_sphinx_theme/pygment.py | 32 ++++++++++++++++------------- src/pydata_sphinx_theme/utils.py | 11 +++++----- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/pydata_sphinx_theme/__init__.py b/src/pydata_sphinx_theme/__init__.py index 403b789e4..fd403b045 100644 --- a/src/pydata_sphinx_theme/__init__.py +++ b/src/pydata_sphinx_theme/__init__.py @@ -18,13 +18,14 @@ logger = logging.getLogger(__name__) + 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 = utils.get_theme_options(app) + theme_options = utils.get_theme_options_dict(app) # TODO: deprecation; remove after 0.14 release if theme_options.get("logo_text"): @@ -278,9 +279,9 @@ def _remove_empty_templates(tname): # Update version number for the "made with version..." component context["theme_version"] = __version__ + def setup(app: Sphinx) -> Dict[str, str]: """Setup the Sphinx application.""" - here = Path(__file__).parent.resolve() theme_path = here / "theme" / "pydata_sphinx_theme" diff --git a/src/pydata_sphinx_theme/logo.py b/src/pydata_sphinx_theme/logo.py index 16e6a0af3..b93784a41 100644 --- a/src/pydata_sphinx_theme/logo.py +++ b/src/pydata_sphinx_theme/logo.py @@ -11,7 +11,7 @@ from sphinx.util import isurl, logging from sphinx.util.fileutil import copy_asset_file -from .utils import get_theme_options +from .utils import get_theme_options_dict logger = logging.getLogger(__name__) @@ -60,8 +60,7 @@ 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. """ - theme_options = get_theme_options(app) - logo = theme_options.get("logo", {}) + 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}") diff --git a/src/pydata_sphinx_theme/pygment.py b/src/pydata_sphinx_theme/pygment.py index 8e3c49581..b39af60eb 100644 --- a/src/pydata_sphinx_theme/pygment.py +++ b/src/pydata_sphinx_theme/pygment.py @@ -10,7 +10,7 @@ from sphinx.application import Sphinx from sphinx.util import logging -from .utils import get_theme_options +from .utils import get_theme_options_dict logger = logging.getLogger(__name__) @@ -74,26 +74,30 @@ def overwrite_pygments_css(app: Sphinx, exception=None): if fallback not in pygments_styles: fallback = pygments_styles[0] # should resolve to "default" - # see if user specified a light/dark pygments theme, if not, use the - # one we set in theme.conf + # see if user specified a light/dark pygments theme: style_key = f"pygment_{light_or_dark}_style" - # globalcontext sometimes doesn't exist so this ensures we do not error - theme_name = get_theme_options(app).get(style_key, None) - if theme_name is None and hasattr(app.builder, "globalcontext"): - theme_name = app.builder.globalcontext.get(f"theme_{style_key}") + 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 theme_name not in pygments_styles: - logger.warning( - f"Color theme {theme_name} not found by pygments, falling back to {fallback}." - ) - theme_name = fallback + 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 = theme_name + light_theme = style_name else: - dark_theme = theme_name + dark_theme = style_name + # re-write pygments.css pygment_css = Path(app.builder.outdir) / "_static" / "pygments.css" with pygment_css.open("w") as f: diff --git a/src/pydata_sphinx_theme/utils.py b/src/pydata_sphinx_theme/utils.py index 5455a2d53..fdbfbbfce 100644 --- a/src/pydata_sphinx_theme/utils.py +++ b/src/pydata_sphinx_theme/utils.py @@ -6,19 +6,20 @@ from sphinx.application import Sphinx -def get_theme_options(app: Sphinx) -> Dict[str, Any]: +def get_theme_options_dict(app: Sphinx) -> Dict[str, Any]: """Return theme options for the application w/ a fallback if they don't exist. - In general we want to modify app.builder.theme_options if it exists, so prefer that first. + 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"): - # In most HTML build cases this will exist except for some circumstances (see below). return app.builder.theme_options elif hasattr(app.config, "html_theme_options"): - # For example, linkcheck will have this configured but won't be in builder obj. return app.config.html_theme_options else: - # Empty dictionary as a fail-safe. return {} From 0d6864734dde4b6236d1e244b91ee24149785ab6 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 29 Mar 2023 17:31:21 -0500 Subject: [PATCH 12/13] typos / docstring improvements / whitespace --- docs/_extension/gallery_directive.py | 2 +- src/pydata_sphinx_theme/pygment.py | 1 - tests/test_build.py | 14 +++++++------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/_extension/gallery_directive.py b/docs/_extension/gallery_directive.py index f3b6d82fa..e80d2c8e7 100644 --- a/docs/_extension/gallery_directive.py +++ b/docs/_extension/gallery_directive.py @@ -151,7 +151,7 @@ def setup(app: Sphinx) -> Dict[str, Any]: Args: app: the Sphinx application Returns: - the 2 parralel parameters set to ``True``. + the 2 parallel parameters set to ``True``. """ app.add_directive("gallery-grid", GalleryDirective) diff --git a/src/pydata_sphinx_theme/pygment.py b/src/pydata_sphinx_theme/pygment.py index b39af60eb..b4d3c602d 100644 --- a/src/pydata_sphinx_theme/pygment.py +++ b/src/pydata_sphinx_theme/pygment.py @@ -76,7 +76,6 @@ def overwrite_pygments_css(app: Sphinx, exception=None): # 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"): diff --git a/tests/test_build.py b/tests/test_build.py index f0e1fe4f3..d49a3d0e1 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -170,7 +170,7 @@ def test_primary_logo_is_light_when_no_default_mode(sphinx_build_factory) -> Non 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 whzn default is set to auto.""" + """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"}, @@ -183,7 +183,7 @@ def test_primary_logo_is_light_when_default_mode_is_set_to_auto( 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 ligh.""" + """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"}, @@ -295,7 +295,7 @@ def test_navbar_align_right(sphinx_build_factory) -> None: def test_navbar_no_in_page_headers(sphinx_build_factory, file_regression) -> None: - """Test No are in page headers.""" + """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() @@ -358,7 +358,7 @@ def test_sidebars_nested_page(sphinx_build_factory, file_regression) -> None: def test_sidebars_level2(sphinx_build_factory, file_regression) -> None: - """Sidebars in a second-level page w/ children.""" + """Test sidebars in a second-level page w/ children.""" confoverrides = {"templates_path": ["_templates_sidebar_level2"]} sphinx_build = sphinx_build_factory("sidebars", confoverrides=confoverrides).build() @@ -691,7 +691,7 @@ def test_version_switcher(sphinx_build_factory, file_regression, url) -> None: def test_theme_switcher(sphinx_build_factory, file_regression) -> None: - """Regression test the theme switcher btn HTML.""" + """Regression test for the theme switcher button.""" sphinx_build = sphinx_build_factory("base").build() switcher = ( sphinx_build.html_tree("index.html") @@ -704,7 +704,7 @@ def test_theme_switcher(sphinx_build_factory, file_regression) -> None: def test_shorten_link(sphinx_build_factory, file_regression) -> None: - """Regression test the shorten links html.""" + """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] @@ -715,7 +715,7 @@ def test_shorten_link(sphinx_build_factory, file_regression) -> None: def test_math_header_item(sphinx_build_factory, file_regression) -> None: - """Regression test the math items in a header title.""" + """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") From 326f97fe6784ff20653e2a0a201c47c1bad970c7 Mon Sep 17 00:00:00 2001 From: Pierrick Rambaud Date: Thu, 30 Mar 2023 07:55:23 +0200 Subject: [PATCH 13/13] fix: expose traverse_or_findall in utils --- src/pydata_sphinx_theme/short_link.py | 15 +-------------- src/pydata_sphinx_theme/utils.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/pydata_sphinx_theme/short_link.py b/src/pydata_sphinx_theme/short_link.py index 8c5460840..052cef99b 100644 --- a/src/pydata_sphinx_theme/short_link.py +++ b/src/pydata_sphinx_theme/short_link.py @@ -1,25 +1,12 @@ """A custom Transform object to shorten github and gitlab links.""" -from typing import Iterator from urllib.parse import ParseResult, urlparse, urlunparse from docutils import nodes -from docutils.nodes import Node from sphinx.transforms.post_transforms import SphinxPostTransform from sphinx.util.nodes import NodeMatcher - -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) - ) +from .utils import traverse_or_findall class ShortenLinkTransform(SphinxPostTransform): diff --git a/src/pydata_sphinx_theme/utils.py b/src/pydata_sphinx_theme/utils.py index fdbfbbfce..6ec4f2084 100644 --- a/src/pydata_sphinx_theme/utils.py +++ b/src/pydata_sphinx_theme/utils.py @@ -1,8 +1,9 @@ """General helpers for the management of config parameters.""" -from typing import Any, Dict +from typing import Any, Dict, Iterator from bs4 import BeautifulSoup, ResultSet +from docutils.nodes import Node from sphinx.application import Sphinx @@ -75,3 +76,16 @@ def extract_level_recursive(ul: ResultSet, navs_list: list) -> None: 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) + )