Skip to content

Instantly share code, notes, and snippets.

@dominiwe
Created August 21, 2025 10:27
Show Gist options
  • Select an option

  • Save dominiwe/0c037d3442508d89c00ff8f6d2943b97 to your computer and use it in GitHub Desktop.

Select an option

Save dominiwe/0c037d3442508d89c00ff8f6d2943b97 to your computer and use it in GitHub Desktop.

Sphinx event callbacks

The sphinx documentation generator can be extended by providing event callbacks. Events fire during different phases of the build and the functions are called.

The page event callbacks documents these. To see when which functions run, this little minimal conf.py file can be used.

# if using uv
uv venv
source .venv/bin/activate
uv pip install sphinx
mkdir _source _build
touch _source/index.rst
# create more pages, add some example content

Create a Makefile:

build:
        sphinx-build -q _source/ _build/

clean:
        rm -rf _build/* _build/.*

.phony: build, clean

Create the _source/conf.py file. Run make build to see when the callbacks run.

from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment
from sphinx.builders import Builder
from sphinx.config import Config
from sphinx.domains import Domain
from sphinx.addnodes import desc_content, pending_xref
from docutils import nodes
from typing import Any, Sequence
from pathlib import Path
import time
import os
DEBUG_VERBOSE = os.environ.get("SPHINX_DEBUG_VERBOSE", "0") == "1"
DEBUG_TIMING = os.environ.get("SPHINX_DEBUG_TIMING", "0") == "1"
DEBUG_COLORS = os.environ.get("SPHINX_DEBUG_COLORS", "1") == "1"
DEBUG_SHOW_ARGS = os.environ.get("SPHINX_DEBUG_SHOW_ARGS", "1") == "1"
DEBUG_INDENT = os.environ.get("SPHINX_DEBUG_INDENT", "0") == "1"
event_times = {}
start_time = None
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
CONFIG = "\033[94m"
ENV_SETUP = "\033[96m"
READ = "\033[92m"
TRANSFORM = "\033[93m"
RESOLVE = "\033[95m"
WRITE = "\033[91m"
FINISH = "\033[90m"
SUCCESS = "\033[92m"
WARNING = "\033[93m"
ERROR = "\033[91m"
INFO = "\033[97m"
def get_color(event_name: str) -> str:
"""Get color for event based on its stage"""
if not DEBUG_COLORS:
return ""
color_map = {
"config-inited": Colors.CONFIG,
"builder-inited": Colors.CONFIG,
"env-get-outdated": Colors.ENV_SETUP,
"env-before-read-docs": Colors.ENV_SETUP,
"env-purge-doc": Colors.ENV_SETUP,
"env-merge-info": Colors.ENV_SETUP,
"env-updated": Colors.ENV_SETUP,
"env-get-updated": Colors.ENV_SETUP,
"env-check-consistency": Colors.ENV_SETUP,
"source-read": Colors.READ,
"include-read": Colors.READ,
"doctree-read": Colors.READ,
"object-description-transform": Colors.TRANSFORM,
"missing-reference": Colors.RESOLVE,
"warn-missing-reference": Colors.RESOLVE,
"doctree-resolved": Colors.RESOLVE,
"write-started": Colors.WRITE,
"html-collect-pages": Colors.WRITE,
"html-page-context": Colors.WRITE,
"linkcheck-process-uri": Colors.WRITE,
"build-finished": Colors.FINISH,
}
return color_map.get(event_name, Colors.INFO)
def format_event_name(event_name: str, indent_level: int = 0) -> str:
"""Format event name with color and indentation"""
color = get_color(event_name)
reset = Colors.RESET if DEBUG_COLORS else ""
bold = Colors.BOLD if DEBUG_COLORS else ""
indent = " " * indent_level if DEBUG_INDENT else ""
if DEBUG_TIMING and event_name in event_times:
elapsed = time.time() - event_times[event_name]
timing = f" ({elapsed:.3f}s)"
else:
timing = ""
return f"{indent}{color}{bold}[{event_name}]{reset}{timing}"
def log_event(event_name: str, details: str = "", indent_level: int = 0):
"""Log an event with optional details"""
global event_times
if not DEBUG_VERBOSE and not details:
print(format_event_name(event_name, indent_level))
elif DEBUG_VERBOSE or details:
formatted_name = format_event_name(event_name, indent_level)
if details and DEBUG_SHOW_ARGS:
print(f"{formatted_name} {details}")
else:
print(formatted_name)
if DEBUG_TIMING:
event_times[event_name] = time.time()
def setup(app: Sphinx) -> dict[str, str | bool | int]:
global start_time
start_time = time.time()
if DEBUG_VERBOSE:
print(
f"\n{Colors.BOLD if DEBUG_COLORS else ''}=== Sphinx Debug Extension Loaded ==={Colors.RESET if DEBUG_COLORS else ''}"
)
print(
f"Verbose: {DEBUG_VERBOSE}, Timing: {DEBUG_TIMING}, Colors: {DEBUG_COLORS}, Args: {DEBUG_SHOW_ARGS}"
)
print("=" * 40 + "\n")
app.connect("config-inited", config_inited)
app.connect("builder-inited", builder_inited)
app.connect("env-get-outdated", env_get_outdated)
app.connect("env-before-read-docs", env_before_read_docs)
app.connect("env-purge-doc", env_purge_doc)
app.connect("source-read", source_read)
app.connect("include-read", include_read)
app.connect("object-description-transform", object_description_transform)
app.connect("doctree-read", doctree_read)
app.connect("missing-reference", missing_reference)
app.connect("warn-missing-reference", warn_missing_reference)
app.connect("doctree-resolved", doctree_resolved)
app.connect("env-merge-info", env_merge_info)
app.connect("env-updated", env_updated)
app.connect("env-get-updated", env_get_updated)
app.connect("env-check-consistency", env_check_consistency)
app.connect("write-started", write_started)
app.connect("build-finished", build_finished)
app.connect("html-collect-pages", html_collect_pages)
app.connect("html-page-context", html_page_context)
app.connect("linkcheck-process-uri", linkcheck_process_uri)
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
def config_inited(app: Sphinx, config: Config) -> None:
"""Emitted when the config object has been initialized"""
details = (
f"Config initialized with project: {config.project}" if DEBUG_SHOW_ARGS else ""
)
log_event("config-inited", details)
def builder_inited(app: Sphinx) -> None:
"""Emitted when the builder object has been created"""
details = f"Builder initialized: {app.builder.name}" if DEBUG_SHOW_ARGS else ""
log_event("builder-inited", details)
def env_get_outdated(
app: Sphinx,
env: BuildEnvironment,
added: set[str],
changed: set[str],
removed: set[str],
) -> Sequence[str]:
"""Emitted when the environment determines which source files have changed"""
details = f"Added: {len(added)}, Changed: {len(changed)}, Removed: {len(removed)}"
log_event("env-get-outdated", details if DEBUG_SHOW_ARGS else "")
if DEBUG_VERBOSE and DEBUG_SHOW_ARGS:
if added:
print(f"\tAdded files: {list(added)[:5]}{'...' if len(added) > 5 else ''}")
if changed:
print(
f"\tChanged files: {list(changed)[:5]}{'...' if len(changed) > 5 else ''}"
)
if removed:
print(
f"\tRemoved files: {list(removed)[:5]}{'...' if len(removed) > 5 else ''}"
)
return []
def env_before_read_docs(
app: Sphinx, env: BuildEnvironment, docnames: list[str]
) -> None:
"""Emitted after environment determined files to read and before reading them"""
details = f"About to read {len(docnames)} documents"
log_event("env-before-read-docs", details if DEBUG_SHOW_ARGS else "")
if DEBUG_VERBOSE and DEBUG_SHOW_ARGS and docnames:
print(f"\tFirst docs: {docnames[:5]}{'...' if len(docnames) > 5 else ''}")
def env_purge_doc(app: Sphinx, env: BuildEnvironment, docname: str) -> None:
"""Emitted when all traces of a source file should be cleaned from environment"""
details = f"Purging: {docname}" if DEBUG_SHOW_ARGS else ""
log_event("env-purge-doc", details, indent_level=1)
def source_read(app: Sphinx, docname: str, content: list[str]) -> None:
"""Emitted when a source file has been read"""
details = (
f"Read document: {docname} ({len(content[0]) if content else 0} chars)"
if DEBUG_SHOW_ARGS
else ""
)
log_event("source-read", details, indent_level=1)
def include_read(
app: Sphinx, relative_path: Path, parent_docname: str, content: list[str]
) -> None:
"""Emitted when a file has been read with the include directive"""
details = (
f"Include from {parent_docname}: {relative_path} ({len(content[0]) if content else 0} chars)"
if DEBUG_SHOW_ARGS
else ""
)
log_event("include-read", details, indent_level=2)
def object_description_transform(
app: Sphinx, domain: str, objtype: str, contentnode: desc_content
) -> None:
"""Emitted when an object description directive has run"""
details = f"Domain: {domain}, Type: {objtype}" if DEBUG_SHOW_ARGS else ""
log_event("object-description-transform", details, indent_level=1)
def doctree_read(app: Sphinx, doctree: nodes.document) -> None:
"""Emitted when a doctree has been parsed and read by the environment"""
docname = doctree.get("source", "unknown")
details = f"Doctree read for: {docname}" if DEBUG_SHOW_ARGS else ""
log_event("doctree-read", details, indent_level=1)
def missing_reference(
app: Sphinx, env: BuildEnvironment, node: pending_xref, contnode: nodes.Node
) -> nodes.Node | None:
"""Emitted when a cross-reference to an object cannot be resolved"""
if DEBUG_SHOW_ARGS:
reftype = node.get("reftype", "unknown")
reftarget = node.get("reftarget", "unknown")
details = f"Cannot resolve {reftype} reference to: {reftarget}"
else:
details = ""
log_event("missing-reference", details, indent_level=2)
return None
def warn_missing_reference(
app: Sphinx, domain: Domain, node: pending_xref
) -> bool | None:
"""Emitted when a cross-reference cannot be resolved even after missing-reference"""
if DEBUG_SHOW_ARGS:
reftype = node.get("reftype", "unknown")
reftarget = node.get("reftarget", "unknown")
details = f"Warning for unresolved {reftype} reference: {reftarget} (domain: {domain.name})"
else:
details = ""
log_event("warn-missing-reference", details, indent_level=2)
return None
def doctree_resolved(app: Sphinx, doctree: nodes.document, docname: str) -> None:
"""Emitted when a doctree has been resolved by the environment"""
details = f"Resolved doctree for: {docname}" if DEBUG_SHOW_ARGS else ""
log_event("doctree-resolved", details, indent_level=1)
def env_merge_info(
app: Sphinx, env: BuildEnvironment, docnames: list[str], other: BuildEnvironment
) -> None:
"""Emitted in parallel mode when subprocess has read some documents"""
details = (
f"Merging {len(docnames)} documents from subprocess" if DEBUG_SHOW_ARGS else ""
)
log_event("env-merge-info", details)
def env_updated(app: Sphinx, env: BuildEnvironment) -> list[str]:
"""Emitted after reading all documents when environment is up-to-date"""
details = (
f"Environment updated, total docs: {len(env.all_docs)}"
if DEBUG_SHOW_ARGS
else ""
)
log_event("env-updated", details)
return []
def env_get_updated(app: Sphinx, env: BuildEnvironment) -> list[str]:
"""Emitted when environment determines which source files have changed"""
log_event("env-get-updated", "Getting updated documents" if DEBUG_SHOW_ARGS else "")
return []
def env_check_consistency(app: Sphinx, env: BuildEnvironment) -> None:
"""Emitted during consistency checks phase"""
details = (
f"Checking consistency for {len(env.all_docs)} documents"
if DEBUG_SHOW_ARGS
else ""
)
log_event("env-check-consistency", details)
def write_started(app: Sphinx, builder: Builder) -> None:
"""Emitted before the builder starts to resolve and write documents"""
details = (
f"Starting write phase with builder: {builder.name}" if DEBUG_SHOW_ARGS else ""
)
log_event("write-started", details)
def build_finished(app: Sphinx, exception: Exception | None) -> None:
"""Emitted when a build has finished, before Sphinx exits"""
global start_time
if DEBUG_SHOW_ARGS:
if exception:
details = f"Build finished with exception: {type(exception).__name__}: {exception}"
else:
details = "Build finished successfully"
else:
details = ""
log_event("build-finished", details)
if DEBUG_VERBOSE and DEBUG_TIMING and start_time:
total_time = time.time() - start_time
print(
f"\n{Colors.BOLD if DEBUG_COLORS else ''}=== Build Summary ==={Colors.RESET if DEBUG_COLORS else ''}"
)
print(f"Total build time: {total_time:.3f}s")
if event_times:
sorted_events = sorted(
[(name, time.time() - t) for name, t in event_times.items()],
key=lambda x: x[1],
reverse=True,
)[:5]
print("\nSlowest events:")
for name, duration in sorted_events:
color = get_color(name)
reset = Colors.RESET if DEBUG_COLORS else ""
print(f" {color}{name}{reset}: {duration:.3f}s")
def html_collect_pages(app: Sphinx) -> list[tuple[str, dict[str, Any], str]]:
"""Emitted when HTML builder is starting to write non-document pages"""
log_event(
"html-collect-pages",
"Collecting additional HTML pages" if DEBUG_SHOW_ARGS else "",
)
return []
def html_page_context(
app: Sphinx,
pagename: str,
templatename: str,
context: dict[str, Any],
doctree: nodes.document | None,
) -> str | None:
"""Emitted when HTML builder has created a context dictionary to render a template"""
if DEBUG_SHOW_ARGS:
has_doctree = doctree is not None
details = (
f"Page: {pagename}, Template: {templatename}, Has doctree: {has_doctree}"
)
else:
details = ""
log_event("html-page-context", details, indent_level=1)
return None
def linkcheck_process_uri(app: Sphinx, uri: str) -> str | None:
"""Emitted when linkcheck builder collects hyperlinks from document"""
details = f"Checking URI: {uri}" if DEBUG_SHOW_ARGS else ""
log_event("linkcheck-process-uri", details, indent_level=1)
return None
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment