Skip to main content
`Ctrl`+`K`

# Source code for pydata_sphinx_theme

    """Bootstrap-based sphinx theme from the PyData community."""

    import json

    from functools import partial
    from pathlib import Path
    from typing import Dict
    from urllib.parse import urlparse

    import requests

    from requests.exceptions import ConnectionError, HTTPError, RetryError
    from sphinx.application import Sphinx
    from sphinx.builders.dirhtml import DirectoryHTMLBuilder
    from sphinx.errors import ExtensionError

    from . import edit_this_page, logo, pygments, short_link, toctree, translator, utils

    __version__ = "0.17.1"

    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_dict(app)
        warning = partial(utils.maybe_warn, app)

        # TODO: DEPRECATE after v1.0
        themes = ["light", "dark"]
        for theme in themes:
            if style := theme_options.get(f"pygment_{theme}_style"):
                theme_options[f"pygments_{theme}_style"] = style
                warning(
                    f'The parameter "pygment_{theme}_style" was renamed to '
                    f'"pygments_{theme}_style" (note the "s" on "pygments").'
                )

        # Validate icon links
        if not isinstance(theme_options.get("icon_links", []), list):
            raise ExtensionError(
                "`icon_links` must be a list of dictionaries, you provided "
                f"type {type(theme_options.get('icon_links'))}."
                "If you wish to disable this feature, either do not provide "
                "a value (leave undefined), or set to an empty list."
            )

        # If the user hasn't explicitly set navbar_persistent, default it based on
        # disable_search: show the search button field unless search is disabled.
        if "navbar_persistent" not in theme_options:
            if theme_options.get("disable_search", False):
                navbar_persistent = []
            else:
                navbar_persistent = ["search-button-field"]
            theme_options["navbar_persistent"] = navbar_persistent

        # Set the anchor link default to be # if the user hasn't provided their own
        if not utils.config_provided_by_user(app, "html_permalinks_icon"):
            app.config.html_permalinks_icon = "#"

        # check the validity of the theme switcher file
        is_dict = isinstance(theme_options.get("switcher"), dict)
        should_test = theme_options.get("check_switcher", True)
        if is_dict and should_test:
            theme_switcher = theme_options.get("switcher")

            # raise an error if one of these compulsory keys is missing
            json_url = theme_switcher["json_url"]
            theme_switcher["version_match"]

            # try to read the json file. If it's a url we use request,
            # else we simply read the local file from the source directory
            # display a log warning if the file cannot be reached
            reading_error = None
            if urlparse(json_url).scheme in ["http", "https"]:
                try:
                    request = requests.get(json_url)
                    request.raise_for_status()
                    content = request.text
                except (ConnectionError, HTTPError, RetryError) as e:
                    reading_error = repr(e)
            else:
                try:
                    content = Path(app.srcdir, json_url).read_text()
                except FileNotFoundError as e:
                    reading_error = repr(e)

            if reading_error is not None:
                warning(
                    f'The version switcher "{json_url}" file cannot be read due to '
                    f"the following error:\n{reading_error}"
                )
            else:
                # check that the json file is not illformed,
                # throw a warning if the file is ill formed and an error if it's not json
                switcher_content = json.loads(content)
                missing_url = any(["url" not in e for e in switcher_content])
                missing_version = any(["version" not in e for e in switcher_content])
                if missing_url or missing_version:
                    warning(
                        f'The version switcher "{json_url}" file is malformed; '
                        'at least one of the items is missing the "url" or "version" key'
                    )

        # Add an analytics ID to the site if provided
        analytics = theme_options.get("analytics", {})
        if analytics:
            # Plausible analytics
            plausible_domain = analytics.get("plausible_analytics_domain")
            plausible_url = analytics.get("plausible_analytics_url")

            # Ref: https://plausible.io/docs/plausible-script
            if plausible_domain and plausible_url:
                kwargs = {
                    "loading_method": "defer",
                    "data-domain": plausible_domain,
                    "filename": plausible_url,
                }
                app.add_js_file(**kwargs)

            # Google Analytics
            gid = analytics.get("google_analytics_id")
            if gid:
                gid_js_path = f"https://www.googletagmanager.com/gtag/js?id={gid}"
                gid_script = f"""
                    window.dataLayer = window.dataLayer || [];
                    function gtag(){{ dataLayer.push(arguments); }}
                    gtag('consent', 'default', {{
                        'ad_storage': 'denied',
                        'ad_user_data': 'denied',
                        'ad_personalization': 'denied',
                        'analytics_storage': 'denied'
                    }});
                    gtag('js', new Date());
                    gtag('config', '{gid}');
                """

                # Link the JS files
                app.add_js_file(gid_js_path, loading_method="async")
                app.add_js_file(None, body=gid_script)

        # Update ABlog configuration default if present
        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
        shortcuts = [
            ("twitter_url", "fa-brands fa-square-twitter", "Twitter"),
            ("bitbucket_url", "fa-brands fa-bitbucket", "Bitbucket"),
            ("gitlab_url", "fa-brands fa-square-gitlab", "GitLab"),
            ("github_url", "fa-brands fa-square-github", "GitHub"),
        ]
        # Add extra icon links entries if there were shortcuts present
        # TODO: Deprecate this at some point in the future?
        icon_links = theme_options.get("icon_links", [])
        for url, icon, name in shortcuts:
            if theme_options.get(url):
                # This defaults to an empty list so we can always insert
                icon_links.insert(
                    0,
                    {
                        "url": theme_options.get(url),
                        "icon": icon,
                        "name": name,
                        "type": "fontawesome",
                    },
                )
        theme_options["icon_links"] = icon_links

        # Prepare the logo config dictionary
        theme_logo = theme_options.get("logo")
        if not theme_logo:
            # In case theme_logo is an empty string
            theme_logo = {}
        if not isinstance(theme_logo, dict):
            raise ValueError(f"Incorrect logo config type: {type(theme_logo)}")
        theme_logo_link = theme_options.get("theme_logo_link")
        if theme_logo_link:
            theme_logo["link"] = theme_logo_link
        theme_options["logo"] = theme_logo

    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 = [
            "theme_navbar_start",
            "theme_navbar_center",
            "theme_navbar_persistent",
            "theme_navbar_end",
            "theme_article_header_start",
            "theme_article_header_end",
            "theme_article_footer_items",
            "theme_content_footer_items",
            "theme_footer_start",
            "theme_footer_center",
            "theme_footer_end",
            "theme_primary_sidebar_end",
            "sidebars",
        ]
        for section in template_sections:
            if context.get(section):
                context[section] = utils._update_and_remove_templates(
                    app=app,
                    context=context,
                    templates=context.get(section, []),
                    section=section,
                    templates_skip_empty_check=["sidebar-nav-bs.html", "navbar-nav.html"],
                )

        # Remove a duplicate entry of the theme CSS. This is because it is in both:
        # - theme.conf
        # - manually linked in `webpack-macros.html`
        if "css_files" in context:
            theme_css_name = "_static/styles/pydata-sphinx-theme.css"
            for i in range(len(context["css_files"])):
                asset = context["css_files"][i]
                # TODO: eventually the contents of context['css_files'] etc should probably
                # only be _CascadingStyleSheet etc. For now, assume mixed with strings.
                asset_path = getattr(asset, "filename", str(asset))
                if asset_path == theme_css_name:
                    del context["css_files"][i]
                    break
        # Add links for favicons in the topbar
        for favicon in context.get("theme_favicons", []):
            icon_type = Path(favicon["href"]).suffix.strip(".")
            opts = {
                "rel": favicon.get("rel", "icon"),
                "sizes": favicon.get("sizes", "16x16"),
                "type": f"image/{icon_type}",
            }
            if "color" in favicon:
                opts["color"] = favicon["color"]
            # Sphinx will auto-resolve href if it's a local file
            app.add_css_file(favicon["href"], **opts)

        # Add metadata to DOCUMENTATION_OPTIONS so that we can re-use later
        # Pagename to current page
        app.add_js_file(None, body=f"DOCUMENTATION_OPTIONS.pagename = '{pagename}';")
        if isinstance(context.get("theme_switcher"), dict):
            theme_switcher = context["theme_switcher"]
            json_url = theme_switcher["json_url"]
            version_match = theme_switcher["version_match"]

            # Add variables to our JavaScript for re-use in our main JS script
            js = f"""
            DOCUMENTATION_OPTIONS.theme_version = '{__version__}';
            DOCUMENTATION_OPTIONS.theme_switcher_json_url = '{json_url}';
            DOCUMENTATION_OPTIONS.theme_switcher_version_match = '{version_match}';
            DOCUMENTATION_OPTIONS.show_version_warning_banner =
                {str(context["theme_show_version_warning_banner"]).lower()};
            """
            app.add_js_file(None, body=js)

        # Specify whether search-as-you-type should be used or not.
        search_as_you_type = str(context["theme_search_as_you_type"]).lower()
        app.add_js_file(
            None, body=f"DOCUMENTATION_OPTIONS.search_as_you_type = {search_as_you_type};"
        )

        # Update version number for the "made with version..." component
        context["theme_version"] = __version__

    def _fix_canonical_url(
        app: Sphinx, pagename: str, templatename: str, context: dict, doctree
    ) -> None:
        """Fix the canonical URL when using the dirhtml builder.

        Sphinx builds a canonical URL if ``html_baseurl`` config is set. However,
        it builds a URL ending with ".html" when using the dirhtml builder, which is
        incorrect. Detect this and generate the correct URL for each page.

        Workaround for https://github.com/sphinx-doc/sphinx/issues/9730; can be removed
        when that is fixed, released, and available in our minimum supported Sphinx version.
        """
        if (
            not app.config.html_baseurl
            or not isinstance(app.builder, DirectoryHTMLBuilder)
            or not context["pageurl"]
            or not context["pageurl"].endswith(".html")
        ):
            return

        target = app.builder.get_target_uri(pagename)
        context["pageurl"] = app.config.html_baseurl + target

    def add_shorten_xform(app: Sphinx) -> None:
        """Add the link shortening transform."""
        theme_options = utils.get_theme_options_dict(app)
        theme_conf_options = app.builder.theme.get_options()
        if (theme_conf_options | theme_options).get("shorten_urls"):
            app.add_post_transform(short_link.ShortenLinkTransform)

    def setup(app: Sphinx) -> Dict[str, str]:
        """Setup the Sphinx application."""
        here = Path(__file__).parent.resolve()
        theme_path = here / "theme" / "pydata_sphinx_theme"
        app.add_html_theme("pydata_sphinx_theme", str(theme_path))

        app.connect("builder-inited", add_shorten_xform)
        app.connect("builder-inited", translator.setup_translators)
        app.connect("builder-inited", update_config)
        app.connect("html-page-context", _fix_canonical_url)
        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", logo.setup_logo_path)
        app.connect("html-page-context", utils.set_secondary_sidebar_items)
        app.connect("build-finished", pygments.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")

        # Include component templates
        app.config.templates_path.append(str(theme_path / "components"))

        return {"parallel_read_safe": True, "parallel_write_safe": True}