"""A lightweight book theme based on the pydata sphinx theme."""
from pathlib import Path
import os
import hashlib
from functools import lru_cache
import subprocess
from datetime import datetime, timezone
from docutils import nodes
from sphinx.util import logging
from bs4 import BeautifulSoup as bs
from sphinx.util.fileutil import copy_asset
from sphinx.util.osutil import ensuredir
from .launch import add_hub_urls
__version__ = "0.18.0"
"""quantecon-book-theme version"""
SPHINX_LOGGER = logging.getLogger(__name__)
MESSAGE_CATALOG_NAME = "booktheme"
def get_html_theme_path():
"""Return list of HTML theme paths."""
parent = Path(__file__).parent.resolve()
theme_path = parent / "theme" / "quantecon_book_theme"
return theme_path
def find_url_relative_to_root(pagename, relative_page, path_docs_source):
"""Given the current page (pagename), a relative page to it (relative_page),
and a path to the docs source, return the path to `relative_page`, but now relative
to the docs source (since this is what keys in Sphinx tend to use).
"""
# In this case, the relative_page is the same as the pagename
if relative_page == "":
relative_page = Path(Path(pagename).name)
# Convert everything to paths for use later
path_rel = Path(relative_page).with_suffix("")
path_parent = Path(pagename) # pagename is relative to docs root
source_dir = Path(path_docs_source)
# This should be the path to `relative_page`, relative to `pagename`
path_rel_from_page_dir = source_dir.joinpath(
path_parent.parent.joinpath(path_rel.parent)
)
path_from_page_dir = path_rel_from_page_dir.resolve()
page_rel_root = path_from_page_dir.relative_to(source_dir).joinpath(path_rel.name)
return page_rel_root
def add_plugins_list(app):
# copying plugins
if "plugins_list" in app.config.html_theme_options:
outdir = app.outdir / "plugins"
ensuredir(outdir)
for i, asset in enumerate(app.config.html_theme_options["plugins_list"]):
assetname = Path(asset).name
copy_asset(app.confdir + "/" + asset, outdir)
app.config.html_theme_options["plugins_list"][i] = "plugins/" + assetname
def get_git_last_modified(source_file, source_dir):
"""Get the last modified date for a source file from git.
Args:
source_file: The source file path relative to source_dir
source_dir: The Sphinx source directory
Returns:
datetime object or None if git is not available
"""
try:
# Get the full path to the source file
file_path = Path(source_dir) / source_file
# Check if git is available and we're in a git repo
result = subprocess.run(
["git", "rev-parse", "--git-dir"],
cwd=source_dir,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return None
# Get the last commit date for this file
result = subprocess.run(
["git", "log", "-1", "--format=%ct", "--follow", "--", str(file_path)],
cwd=source_dir,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
timestamp = int(result.stdout.strip())
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
except (
subprocess.TimeoutExpired,
subprocess.SubprocessError,
ValueError,
FileNotFoundError,
):
pass
return None
def get_git_changelog(source_file, source_dir, max_entries=10):
"""Get the changelog for a source file from git.
Args:
source_file: The source file path relative to source_dir
source_dir: The Sphinx source directory
max_entries: Maximum number of changelog entries to return
Returns:
List of dicts with keys: hash, author, date, message, relative_time
Empty list if git is not available
"""
try:
# Get the full path to the source file
file_path = Path(source_dir) / source_file
# Check if git is available and we're in a git repo
result = subprocess.run(
["git", "rev-parse", "--git-dir"],
cwd=source_dir,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return []
# Get the changelog with format: hash|author|timestamp|subject
result = subprocess.run(
[
"git",
"log",
f"-{max_entries}",
"--format=%h|%an|%ct|%s",
"--follow",
"--",
str(file_path),
],
cwd=source_dir,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0 or not result.stdout.strip():
return []
changelog = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("|", 3)
if len(parts) == 4:
commit_hash, author, timestamp, message = parts
commit_time = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
relative_time = get_relative_time(commit_time)
changelog.append(
{
"hash": commit_hash,
"author": author,
"date": commit_time,
"message": message,
"relative_time": relative_time,
}
)
return changelog
except (
subprocess.TimeoutExpired,
subprocess.SubprocessError,
ValueError,
FileNotFoundError,
):
pass
return []
def get_relative_time(past_date):
"""Convert a datetime to relative time string (e.g., '3 months ago')."""
now = datetime.now(timezone.utc)
# Ensure past_date is timezone-aware for comparison
if past_date.tzinfo is None:
past_date = past_date.replace(tzinfo=timezone.utc)
diff = now - past_date
seconds = diff.total_seconds()
if seconds < 60:
return "just now"
elif seconds < 3600:
minutes = int(seconds / 60)
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
elif seconds < 86400:
hours = int(seconds / 3600)
return f"{hours} hour{'s' if hours != 1 else ''} ago"
elif seconds < 604800:
days = int(seconds / 86400)
return f"{days} day{'s' if days != 1 else ''} ago"
elif seconds < 2592000:
weeks = int(seconds / 604800)
return f"{weeks} week{'s' if weeks != 1 else ''} ago"
elif seconds < 31536000:
months = int(seconds / 2592000)
return f"{months} month{'s' if months != 1 else ''} ago"
else:
years = int(seconds / 31536000)
return f"{years} year{'s' if years != 1 else ''} ago"
[docs]
def add_to_context(app, pagename, templatename, context, doctree):
"""Functions and variable additions to context."""
config_theme = app.config.html_theme_options
def sbt_generate_toctree_html(
level=1,
include_item_names=False,
with_home_page=False,
):
# Config stuff
if isinstance(with_home_page, str):
with_home_page = with_home_page.lower() == "true"
# Grab the raw toctree object and structure it so we can manipulate it
toctree = context["generate_toctree_html"](
startdepth=level - 1,
maxdepth=level + 1,
kind="sidebar",
collapse=False,
titles_only=True,
includehidden=True,
)
# toctree = bs(toc_sphinx, "html.parser")
# pair "current" with "active" since that's what we use w/ bootstrap
for li in toctree("li", {"class": "current"}):
li["class"].append("active")
# Add the master_doc page as the first item if specified
if with_home_page:
master_title = master_doctree.traverse(nodes.title)[0].astext()
if len(master_title) == 0:
raise ValueError(f"Landing page missing a title: {master_doc}")
li_class = "toctree-l1"
if context["pagename"] == master_doc:
li_class += " current"
# Insert it into our toctree
ul_home = bs(
f"""
<ul class="nav bd-sidenav">
<li class="{li_class}">
<a href="{master_url}" class="reference internal">{master_title}</a>
</li>
</ul>""",
"html.parser",
)
toctree.insert(0, ul_home("ul")[0])
# Add an icon for external links
for a_ext in toctree("a", attrs={"class": ["external"]}):
a_ext.append(
toctree.new_tag("i", attrs={"class": ["fas", "fa-external-link-alt"]})
)
# Add bootstrap classes for first `ul` items
for ul in toctree("ul", recursive=False):
ul.attrs["class"] = ul.attrs.get("class", []) + ["nav", "sidenav_l1"]
return toctree.prettify()
def generate_toc_html():
"""Return the within-page TOC links in HTML."""
if not context.get("toc"):
return ""
soup = bs(context["toc"], "html.parser")
# Add toc-hN classes
def add_header_level_recursive(ul, level):
for li in ul("li", recursive=False):
li["class"] = li.get("class", []) + [f"toc-h{level}"]
ul = li.find("ul", recursive=False)
if ul:
add_header_level_recursive(ul, 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"]
# Keep only the sub-sections of the title (so no title is shown)
title = soup.find("a", attrs={"href": "#"})
if title:
title = title.parent
# Only show if children of the title item exist
if title.select("ul li"):
out = title.find("ul").prettify()
else:
out = ""
else:
out = ""
return out
def get_github_src_folder(app):
if "github_repo" in context:
github_repo = context["github_repo"]
if github_repo in str(app.srcdir):
index = str(app.srcdir).rfind(github_repo)
branch = config_theme.get("nb_branch", "")
if branch == "":
branch = "main"
folder = str(app.srcdir)[index + len(github_repo) :]
return "/blob/" + branch + folder
return ""
# Pull metadata about the master doc
master_doc = app.config["master_doc"]
master_doctree = app.env.get_doctree(master_doc)
master_url = context["pathto"](master_doc)
context["master_url"] = master_url
context["sbt_generate_toctree_html"] = sbt_generate_toctree_html
context["generate_toc_html"] = generate_toc_html
# check if book pdf folder is present
if os.path.isdir(app.outdir / "_pdf"):
if "pdf_book_name" not in context:
context["pdf_book_name"] = app.config.latex_documents[0][1].replace(
".tex", ""
)
context["pdf_book_path"] = "/_pdf/" + context["pdf_book_name"] + ".pdf"
# check if notebook folder is present
if os.path.isdir(app.outdir / "_notebooks"):
if "download_nb_path" in app.config.html_theme_options:
context["notebook_path"] = (
app.config.html_theme_options["download_nb_path"]
+ "/_notebooks/"
+ context["pagename"]
+ ".ipynb"
)
else:
context["notebook_path"] = "/_notebooks/" + context["pagename"] + ".ipynb"
# Update the page title because HTML makes it into the page title occasionally
if pagename in app.env.titles:
title = app.env.titles[pagename]
context["pagetitle"] = title.astext()
# Add a shortened page text to the context using the sections text
if not len(context["theme_description"]) > 0 and doctree:
description = ""
for section in doctree.traverse(nodes.section):
description += section.astext().replace("\n", " ")
description = description[:160]
context["theme_description"] = description
# Add the author if it exists
if app.config.author != "unknown":
context["author"] = app.config.author
# Absolute URLs for logo if `html_baseurl` is given
# pageurl will already be set by Sphinx if so
if app.config.html_baseurl and app.config.html_logo:
context["logourl"] = "/".join(
(app.config.html_baseurl.rstrip("/"), context["logo_url"])
)
# Check mathjax version and set it in a variable
if app.config["mathjax_path"] and "@3" in app.config["mathjax_path"]:
context["mathjax_version"] = 3
else:
context["mathjax_version"] = 2
# Add HTML context variables that the pydata theme uses that we configure elsewhere
# For some reason the source_suffix sometimes isn't there even when doctree is
if doctree and context.get("page_source_suffix"):
repo_url = config_theme.get("repository_url", "")
# Only add the edit button if `repository_url` is given
if repo_url:
branch = config_theme.get("repository_branch")
if not branch:
# Explicitly check in case branch is ""
branch = "main"
relpath = config_theme.get("path_to_docs", "")
org, repo = repo_url.strip("/").split("/")[-2:]
context.update(
{
"github_user": org,
"github_repo": repo,
"github_version": branch,
"doc_path": relpath,
}
)
else:
# Disable using the button so we don't get errors
context["theme_use_edit_page_button"] = False
# default value is book.tex
if "pdf_book_name" not in context:
context["pdf_book_name"] = app.config.latex_documents[0][1].replace(".tex", "")
context["github_sourcefolder"] = get_github_src_folder(app)
# Add git information (last modified date and changelog)
if doctree and hasattr(app.env, "doc2path"):
source_file = app.env.doc2path(pagename, base=False)
source_dir = app.srcdir
# Get last modified date
last_modified = get_git_last_modified(source_file, source_dir)
if last_modified:
# Get date format from theme options, default to "%b %d, %Y"
date_format = config_theme.get("last_modified_date_format", "%b %d, %Y")
context["last_modified_date"] = last_modified.strftime(date_format)
context["last_modified_iso"] = last_modified.isoformat()
else:
context["last_modified_date"] = None
# Get changelog entries
max_changelog_entries = config_theme.get("changelog_max_entries", 10)
changelog = get_git_changelog(source_file, source_dir, max_changelog_entries)
context["changelog_entries"] = changelog
context["has_git_info"] = last_modified is not None and len(changelog) > 0
# Add repository URL and source file for GitHub links
repo_url = config_theme.get("repository_url", "")
if repo_url:
context["theme_repository_url"] = repo_url.rstrip("/")
# Construct full path including path_to_docs
path_to_docs = config_theme.get("path_to_docs", "")
if path_to_docs:
full_source_path = f"{path_to_docs}/{source_file}".replace("//", "/")
else:
full_source_path = source_file
context["theme_source_file"] = full_source_path
else:
context["theme_repository_url"] = None
context["theme_source_file"] = None
else:
context["last_modified_date"] = None
context["changelog_entries"] = []
context["has_git_info"] = False
context["theme_repository_url"] = None
context["theme_source_file"] = None
# Make sure the context values are bool
blns = [
"theme_use_edit_page_button",
"theme_use_repository_button",
"theme_use_issues_button",
"theme_enable_rtl",
]
for key in blns:
if key in context:
context[key] = _string_or_bool(context[key])
@lru_cache(maxsize=None)
def _gen_hash(path: str) -> str:
return hashlib.sha1(path.read_bytes()).hexdigest()
def hash_assets_for_files(assets: list, theme_static: Path, context):
"""Generate a hash for assets, and append to its entry in context.
assets: a list of assets to hash, each path should be relative to
the theme's static folder.
theme_static: a path to the theme's static folder.
context: the Sphinx context object where asset links are stored. These are:
`css_files` and `script_files` keys.
"""
for asset in assets:
# CSS assets are stored in css_files, JS assets in script_files
asset_type = "css_files" if asset.endswith(".css") else "script_files"
if asset_type in context:
# Define paths to the original asset file, and its linked file in Sphinx
asset_sphinx_link = f"_static/{asset}"
asset_source_path = theme_static / asset
if not asset_source_path.exists():
SPHINX_LOGGER.warning(
f"Asset {asset_source_path} does not exist, not linking."
)
# Find this asset in context, and update it to include the digest
# Use .filename attribute to avoid deprecation warnings in Sphinx 9+
for i, css_or_js in enumerate(context[asset_type]):
filename = getattr(css_or_js, "filename", None)
# Skip if filename attribute doesn't exist
if filename is None:
continue
if filename == asset_sphinx_link:
hash = _gen_hash(asset_source_path)
context[asset_type][i] = asset_sphinx_link + "?digest=" + hash
break
def hash_html_assets(app, pagename, templatename, context, doctree):
"""Add ?digest={hash} to assets in order to bust cache when changes are made.
The source files are in `static` while the built HTML is in `_static`.
"""
assets = ["scripts/quantecon-book-theme.js"]
# Only append the book theme CSS if it's explicitly this theme. Sub-themes
# will define their own CSS file, so if a sub-theme is used, this code is
# run but the book theme CSS file won't be linked in Sphinx.
if app.config.html_theme == "quantecon_book_theme":
assets.append("styles/quantecon-book-theme.css")
hash_assets_for_files(assets, get_html_theme_path() / "static", context)
def add_pygments_style_class(app, pagename, templatename, context, doctree):
"""Add CSS class to root element if QuantEcon theme code style is disabled.
When qetheme_code_style is False, adds 'use-pygments-style' class which
disables the custom QuantEcon code token styles and allows Pygments
built-in styles (configured via pygments_style) to be used.
"""
config_theme = app.config.html_theme_options
qetheme_code_style = config_theme.get("qetheme_code_style", True)
# Convert string "false"/"true" to boolean if needed
if isinstance(qetheme_code_style, str):
qetheme_code_style = qetheme_code_style.lower() != "false"
# Set a context variable that can be used in templates
context["use_pygments_style"] = not qetheme_code_style
def setup_pygments_css(app):
"""Ensure Pygments CSS is included when using Pygments styles.
This runs during builder-inited, after config is fully loaded.
We generate our own unscoped pygments CSS file instead of using
Sphinx's scoped version.
"""
from pygments.formatters import HtmlFormatter
# Access html_theme_options from app.config (it's a dict)
config_theme = getattr(app.config, "html_theme_options", {})
qetheme_code_style = config_theme.get("qetheme_code_style", True)
# Convert string "false"/"true" to boolean if needed
if isinstance(qetheme_code_style, str):
qetheme_code_style = qetheme_code_style.lower() != "false"
# When using Pygments styles, generate and include unscoped CSS
if not qetheme_code_style:
# Get the Pygments style name from config (default to 'default')
pygments_style = getattr(app.config, "pygments_style", None) or "default"
# Generate CSS without data-theme scoping
formatter = HtmlFormatter(style=pygments_style)
css_content = formatter.get_style_defs(".highlight")
# Write CSS file to _static directory with a different name
# This ensures it won't be overwritten by Sphinx or pydata-sphinx-theme
static_dir = Path(app.outdir) / "_static"
static_dir.mkdir(parents=True, exist_ok=True)
pygments_css_path = static_dir / "pygments-quantecon.css"
pygments_css_path.write_text(css_content)
# Add the CSS file to the page (instead of the default pygments.css)
app.add_css_file("pygments-quantecon.css")
def _string_or_bool(var):
if isinstance(var, str):
return var.lower() == "true"
elif isinstance(var, bool):
return var
else:
return var is None
# Built-in text color schemes
_VALID_COLOR_SCHEMES = ["seoul256", "gruvbox", "none"]
def validate_color_scheme(app):
"""Validate the color_scheme theme option.
Ensures the selected scheme is a known built-in scheme name. Invalid values
fall back to the default 'seoul256' scheme with a warning.
Also checks for a custom_color_scheme.css in the project's _static
directories and automatically includes it if found.
"""
theme_options = app.config.html_theme_options
scheme = theme_options.get("color_scheme", "seoul256").strip().lower()
if scheme not in _VALID_COLOR_SCHEMES:
SPHINX_LOGGER.warning(
"Unknown color_scheme %r. Valid schemes: %s. Falling back to 'seoul256'.",
scheme,
", ".join(_VALID_COLOR_SCHEMES),
)
theme_options["color_scheme"] = "seoul256"
else:
theme_options["color_scheme"] = scheme
# Auto-detect custom_color_scheme.css in _static directories
static_paths = getattr(app.config, "html_static_path", [])
confdir = Path(app.confdir) if app.confdir else None
for static_path in static_paths:
if confdir:
full_path = confdir / static_path / "custom_color_scheme.css"
if full_path.is_file():
app.add_css_file("custom_color_scheme.css")
SPHINX_LOGGER.info(
"Loading custom text color scheme from %s", full_path
)
break
def setup(app):
# Configuration for Juypter Book
app.setup_extension("sphinx_book_theme")
app.add_js_file("scripts/quantecon-book-theme.js")
app.add_js_file("scripts/jquery.js")
app.add_js_file("scripts/_sphinx_javascript_frameworks_compat.js")
app.connect("html-page-context", add_hub_urls)
app.connect("builder-inited", add_plugins_list)
app.connect("builder-inited", validate_color_scheme)
app.connect("builder-inited", setup_pygments_css)
app.connect("html-page-context", hash_html_assets)
app.connect("html-page-context", add_pygments_style_class)
app.add_html_theme("quantecon_book_theme", get_html_theme_path())
app.connect("html-page-context", add_to_context)
return {
"parallel_read_safe": True,
"parallel_write_safe": True,
}