"""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
[docs]
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'))}."
)
# 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('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
[docs]
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)
# Update version number for the "made with version..." component
context["theme_version"] = __version__
[docs]
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
[docs]
def setup(app: Sphinx) -> Dict[str, str]:
"""Setup the Sphinx application."""
here = Path(__file__).parent.resolve()
theme_path = here / "theme" / "pydata_sphinx_theme"
app.add_html_theme("pydata_sphinx_theme", str(theme_path))
app.add_post_transform(short_link.ShortenLinkTransform)
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}