|
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 |