import os.path
import shutil
import subprocess
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Callable, Dict, List, Optional
from jinja2 import Environment, FileSystemLoader
from .utils import get_config
__all__ = ("generate_docs_configuration", "run_doxygen_if_needed", "generate_wiki_configuration")
[docs]
def run_doxygen_if_needed(
breathe_projects: Dict[str, str],
*,
force: bool = False,
quiet: bool = False,
) -> Dict[str, bool]:
"""Run doxygen for breathe projects if needed.
For each project in breathe_projects, checks if the XML output directory
exists. If not, attempts to find a Doxyfile in the parent directory and
runs doxygen to generate the XML.
Args:
breathe_projects: Dict mapping project names to XML output directories.
force: If True, run doxygen even if XML directory already exists.
quiet: If True, suppress doxygen output.
Returns:
Dict mapping project names to whether doxygen was run successfully.
Returns empty dict if doxygen is not installed.
Example:
>>> results = run_doxygen_if_needed({"mylib": "docs/xml"})
>>> if results.get("mylib"):
... print("Doxygen ran successfully")
"""
# Check if doxygen is available
doxygen_path = shutil.which("doxygen")
if not doxygen_path:
return {}
results = {}
for project_name, xml_path in breathe_projects.items():
xml_dir = Path(xml_path)
# Check if XML already exists (unless force is True)
if not force and xml_dir.exists() and any(xml_dir.glob("*.xml")):
results[project_name] = True
continue
# Try to find Doxyfile in parent directory of XML output
# Common patterns: xml is in same dir as Doxyfile, or xml/ subdir
search_dirs = [
xml_dir.parent, # xml is a subdirectory
xml_dir, # xml output is in same directory as Doxyfile
]
doxyfile_path = None
for search_dir in search_dirs:
candidate = search_dir / "Doxyfile"
if candidate.exists():
doxyfile_path = candidate
break
if not doxyfile_path:
if not quiet:
print(f"Warning: No Doxyfile found for project '{project_name}'")
results[project_name] = False
continue
# Run doxygen
try:
if not quiet:
print(f"Running doxygen for project '{project_name}'...")
kwargs = {"cwd": doxyfile_path.parent}
if quiet:
kwargs["stdout"] = subprocess.DEVNULL
kwargs["stderr"] = subprocess.DEVNULL
result = subprocess.run([doxygen_path], **kwargs)
results[project_name] = result.returncode == 0
if not quiet and result.returncode == 0:
print(f" Generated XML documentation in {xml_dir}")
elif not quiet:
print(f" Doxygen failed with return code {result.returncode}")
except Exception as e:
if not quiet:
print(f" Error running doxygen: {e}")
results[project_name] = False
return results
[docs]
@contextmanager
def generate_docs_configuration(
*,
project: Optional[str] = None,
title: Optional[str] = None,
module: Optional[str] = None,
description: Optional[str] = None,
author: Optional[str] = None,
copyright: Optional[str] = None,
version: Optional[str] = None,
theme: Optional[str] = None,
docs_root: Optional[str] = None,
root: Optional[str] = None,
cname: Optional[str] = None,
pages: Optional[List] = None,
use_autoapi: Optional[bool] = None,
autoapi_ignore: Optional[List] = None,
custom_css: Optional[Path] = None,
custom_js: Optional[Path] = None,
config_base: Optional[str] = None,
previous_versions: Optional[bool] = False,
adjust_arguments: Callable = None,
adjust_template: Callable = None,
):
"""Generate Sphinx documentation configuration from pyproject.toml.
A context manager that creates a temporary Sphinx configuration (conf.py)
based on settings from pyproject.toml and yields the configuration directory
path for use with sphinx-build. If a conf.py already exists in the current
directory, it yields the current directory instead.
Configuration is read from the ``[tool.yardang]`` section of pyproject.toml
by default, with breathe/doxygen settings in ``[tool.yardang.breathe]``.
Args:
project: Project name. Falls back to ``[project].name`` or directory name.
title: Documentation title. Falls back to ``[tool.yardang].title`` or project name.
module: Python module name for autoapi. Falls back to project name with
hyphens replaced by underscores.
description: Project description for metadata.
author: Author name. Falls back to first entry in ``[project].authors``.
copyright: Copyright string. Falls back to author name.
version: Version string. Falls back to ``[project].version``.
theme: Sphinx theme name. Defaults to ``"furo"``.
docs_root: Base URL for hosted documentation. Used for canonical URLs.
root: Path to README or index file to use as documentation root.
cname: Custom domain name for GitHub Pages CNAME file.
pages: List of page paths to include in the toctree.
use_autoapi: Whether to use sphinx-autoapi for Python API docs.
Defaults to ``None`` (auto-detect).
custom_css: Path to custom CSS file. Defaults to bundled custom.css.
custom_js: Path to custom JavaScript file. Defaults to bundled custom.js.
config_base: Base key in pyproject.toml for configuration.
Defaults to ``"tool.yardang"``.
previous_versions: Whether to generate previous versions documentation.
adjust_arguments: Callback to modify template arguments before rendering.
Receives the args dict and should return the modified dict.
adjust_template: Callback to modify the Jinja2 template before rendering.
Receives the template and should return the modified template.
Yields:
str: Path to directory containing the generated conf.py file,
or the current directory if conf.py already exists.
Raises:
FileNotFoundError: If custom_css or custom_js paths don't exist.
toml.TomlDecodeError: If pyproject.toml is malformed.
Example:
Basic usage with sphinx-build::
from yardang import generate_docs_configuration
with generate_docs_configuration() as config_dir:
subprocess.run(["sphinx-build", "-c", config_dir, ".", "docs/html"])
With custom arguments callback::
def customize(args):
args["html_theme_options"]["sidebar_hide_name"] = True
return args
with generate_docs_configuration(adjust_arguments=customize) as config_dir:
# build docs...
Note:
Breathe/Doxygen configuration is loaded from ``[tool.yardang.breathe]``
with the following options:
- ``projects``: Dict mapping project names to Doxygen XML directories
- ``default-project``: Default project for breathe directives
- ``domain-by-extension``: Map file extensions to Sphinx domains
- ``show-define-initializer``: Show macro initializer values (default: True)
- ``show-enumvalue-initializer``: Show enum value initializers (default: True)
- ``show-include``: Show #include directives (default: True)
- ``use-project-refids``: Prefix refids with project name (default: True)
"""
config_base = config_base if config_base is not None else "tool.yardang"
if os.path.exists("conf.py"):
# yield folder path to sphinx build
yield os.path.curdir
else:
# load configuration
default_data = os.path.split(os.getcwd())[-1]
project = project if project is not None else get_config(section="name", base="project") or default_data.replace("_", "-")
title = title if title is not None else get_config(section="title", base=config_base) or default_data.replace("_", "-")
module = (
module
if module is not None
else get_config(section="module", base=config_base) or project.replace("-", "_") or default_data.replace("-", "_")
)
description = (
description
if description is not None
else get_config(section="name", base="description") or default_data.replace("_", " ").replace("-", " ")
)
author = author if author is not None else get_config(section="authors", base="project")
if isinstance(author, list) and len(author) > 0:
author = author[0]
else:
author = f"The {project} authors"
if isinstance(author, dict):
author = author["name"]
copyright = copyright if copyright is not None else author
theme = theme if theme is not None else get_config(section="theme", base=config_base) or "furo"
version = version if version is not None else get_config(section="version", base="project")
docs_root = (
docs_root
if docs_root is not None
else get_config(section="docs-host", base=config_base)
or get_config(section="urls.Homepage", base="project")
or get_config(section="urls.homepage", base="project")
or get_config(section="urls.Documentation", base="project")
or get_config(section="urls.documentation", base="project")
or get_config(section="urls.Source", base="project")
or get_config(section="urls.source", base="project")
or ""
)
root = root if root is not None else get_config(section="root", base=config_base)
cname = cname if cname is not None else get_config(section="cname", base=config_base)
pages = pages if pages is not None else get_config(section="pages", base=config_base) or []
use_autoapi = use_autoapi if use_autoapi is not None else get_config(section="use-autoapi", base=config_base)
autoapi_ignore = autoapi_ignore if autoapi_ignore is not None else get_config(section="docs.autoapi-ignore")
custom_css = (
custom_css
if custom_css is not None
else Path(get_config(section="custom-css", base=config_base) or (Path(__file__).parent / "custom.css"))
)
custom_js = (
custom_js if custom_js is not None else Path(get_config(section="custom-js", base=config_base) or (Path(__file__).parent / "custom.js"))
)
# if custom_css and custom_js are strings and they exist as paths, read them as Paths
# otherwise, assume the content is directly provided
if isinstance(custom_css, str):
custom_css_path = Path(custom_css)
# if the path is too long, it will throw
try:
if custom_css_path.exists():
custom_css = custom_css_path.read_text()
except OSError:
pass
else:
custom_css = custom_css.read_text()
if isinstance(custom_js, str):
custom_js_path = Path(custom_js)
try:
if custom_js_path.exists():
custom_js = custom_js_path.read_text()
except OSError:
pass
else:
custom_js = custom_js.read_text()
source_dir = os.path.curdir
configuration_args = {}
for config_option, default in {
# sphinx generic
"html_theme_options": {},
"html_static_path": [],
"html_css_files": [],
"html_js_files": [],
"source_suffix": [],
"exclude_patterns": [],
"language": "en",
"pygments_style": "sphinx",
# myst/myst-nb
"myst_enable_extensions": [
"amsmath",
# "attrs_inline",
"colon_fence",
# "deflist",
"dollarmath",
# "fieldlist",
# "html_admonition",
"html_image",
# "linkify",
# "replacements",
# "smartquotes",
# "strikethrough",
# "substitution",
# "tasklist",
],
"myst_fence_as_directive": [
"mermaid",
# breathe/doxygen directives
"doxygenindex",
"doxygenfunction",
"doxygenstruct",
"doxygenclass",
"doxygennamespace",
"doxygengroup",
"doxygentypedef",
"doxygenenum",
"doxygenfile",
"doxygendefine",
"doxygenunion",
"doxygenvariable",
# sphinx-rust directives
"rust:crate",
"rust:module",
"rust:struct",
"rust:enum",
"rust:function",
"rust:method",
"rust:trait",
"rust:impl",
"rust:type",
"rust:const",
"rust:static",
"rust:macro",
# sphinx-js directives
"js:autofunction",
"js:autoclass",
"js:autoattribute",
"js:automodule",
"js:autosummary",
# common sphinx directives
"toctree",
"literalinclude",
"include",
],
"nb_execution_mode": "off",
"nb_execution_excludepatterns": [],
# autodoc/autodoc-pydantic
"autodoc_pydantic_field_list_validators": None,
"autodoc_pydantic_field_show_constraints": None,
"autodoc_pydantic_model_member_order": "bysource",
"autodoc_pydantic_model_show_config_summary": None,
"autodoc_pydantic_model_show_field_summary": None,
"autodoc_pydantic_model_show_json": True,
"autodoc_pydantic_model_show_validator_summary": None,
"autodoc_pydantic_model_show_validator_members": None,
"autodoc_pydantic_settings_show_json": None,
# sphinx-reredirects
"redirects": {},
}.items():
configuration_args[config_option] = get_config(section=config_option, base=config_base) or default
# Load breathe/doxygen configuration from tool.yardang.breathe
breathe_config_base = f"{config_base}.breathe"
breathe_args = {}
for config_option, default in {
# breathe/doxygen
"breathe_projects": {},
"breathe_default_project": "",
"breathe_domain_by_extension": {"h": "cpp", "hpp": "cpp", "cpp": "cpp", "c": "c", "py": "py", "cs": "cs"},
"breathe_domain_by_file_pattern": {},
"breathe_projects_source": {},
"breathe_build_directory": "",
"breathe_default_members": (),
"breathe_show_define_initializer": True,
"breathe_show_enumvalue_initializer": True,
"breathe_show_include": True,
"breathe_implementation_filename_extensions": [".c", ".cc", ".cpp"],
"breathe_doxygen_config_options": {},
"breathe_doxygen_aliases": {},
"breathe_use_project_refids": True,
"breathe_order_parameters_first": False,
"breathe_separate_member_pages": False,
}.items():
# config keys in toml use hyphens, not underscores, and no breathe_ prefix
toml_key = config_option.replace("breathe_", "").replace("_", "-")
breathe_args[config_option] = get_config(section=toml_key, base=breathe_config_base)
if breathe_args[config_option] is None:
breathe_args[config_option] = default
# Determine if breathe should be used
use_breathe = bool(breathe_args["breathe_projects"] or breathe_args["breathe_projects_source"] or breathe_args["breathe_default_project"])
# Auto-run doxygen if configured and projects exist
auto_run_doxygen = get_config(section="auto-run-doxygen", base=breathe_config_base)
if auto_run_doxygen is None:
auto_run_doxygen = True # Default to True
if use_breathe and auto_run_doxygen and breathe_args["breathe_projects"]:
run_doxygen_if_needed(breathe_args["breathe_projects"])
# Convert relative paths in breathe_projects to absolute paths
# This is needed because the conf.py is generated in a temp directory
if breathe_args["breathe_projects"]:
breathe_args["breathe_projects"] = {
name: str(Path(path).resolve()) if not Path(path).is_absolute() else path for name, path in breathe_args["breathe_projects"].items()
}
# Load sphinx-rust configuration from tool.yardang.sphinx-rust
rust_config_base = f"{config_base}.sphinx-rust"
rust_args = {}
for config_option, default in {
# sphinx-rust
"rust_crates": [],
"rust_doc_formats": {},
"rust_viewcode": True,
}.items():
# config keys in toml use hyphens, not underscores, and no rust_ prefix
toml_key = config_option.replace("rust_", "").replace("_", "-")
rust_args[config_option] = get_config(section=toml_key, base=rust_config_base)
if rust_args[config_option] is None:
rust_args[config_option] = default
# Determine if sphinx-rust should be used
use_sphinx_rust = bool(rust_args["rust_crates"])
# Convert relative paths in rust_crates to absolute paths
if rust_args["rust_crates"]:
rust_args["rust_crates"] = [str(Path(path).resolve()) if not Path(path).is_absolute() else path for path in rust_args["rust_crates"]]
# Load sphinx-js configuration from tool.yardang.sphinx-js
js_config_base = f"{config_base}.sphinx-js"
js_args = {}
for config_option, default in {
# sphinx-js
"js_language": "javascript",
"js_source_path": [],
"root_for_relative_js_paths": "",
"jsdoc_config_path": "",
"jsdoc_tsconfig_path": "",
"ts_type_bold": False,
}.items():
# config keys in toml use hyphens, not underscores, and no js_ prefix for some
toml_key = config_option.replace("_", "-")
js_args[config_option] = get_config(section=toml_key, base=js_config_base)
if js_args[config_option] is None:
js_args[config_option] = default
# Determine if sphinx-js should be used
use_sphinx_js = bool(js_args["js_source_path"])
# Convert relative paths in js_source_path to absolute paths
if js_args["js_source_path"]:
if isinstance(js_args["js_source_path"], str):
js_args["js_source_path"] = str(Path(js_args["js_source_path"]).resolve())
else:
js_args["js_source_path"] = [
str(Path(path).resolve()) if not Path(path).is_absolute() else path for path in js_args["js_source_path"]
]
# Convert relative paths for jsdoc config files
if js_args["jsdoc_config_path"]:
js_args["jsdoc_config_path"] = str(Path(js_args["jsdoc_config_path"]).resolve())
if js_args["jsdoc_tsconfig_path"]:
js_args["jsdoc_tsconfig_path"] = str(Path(js_args["jsdoc_tsconfig_path"]).resolve())
if js_args["root_for_relative_js_paths"]:
js_args["root_for_relative_js_paths"] = str(Path(js_args["root_for_relative_js_paths"]).resolve())
# Load wiki configuration from tool.yardang.wiki
wiki_config_base = f"{config_base}.wiki"
wiki_args = {}
for config_option, default in {
# wiki/markdown builder
"wiki_enabled": False,
"wiki_output_dir": "docs/wiki",
"wiki_generate_sidebar": True,
"wiki_generate_footer": True,
"wiki_fix_links": True,
"wiki_footer_docs_url": "",
"wiki_footer_repo_url": "",
# sphinx-markdown-builder options
"markdown_anchor_sections": True,
"markdown_anchor_signatures": True,
"markdown_docinfo": False,
"markdown_http_base": "",
"markdown_uri_doc_suffix": ".md",
"markdown_bullet": "-",
"markdown_flavor": "github",
}.items():
# config keys in toml use hyphens, not underscores, and no wiki_ prefix for wiki-specific options
if config_option.startswith("wiki_"):
toml_key = config_option.replace("wiki_", "").replace("_", "-")
else:
toml_key = config_option.replace("_", "-")
wiki_args[config_option] = get_config(section=toml_key, base=wiki_config_base)
if wiki_args[config_option] is None:
wiki_args[config_option] = default
# Determine if wiki/markdown output should be generated
use_wiki = wiki_args["wiki_enabled"]
# create a temporary directory to store the conf.py file in
with TemporaryDirectory() as td:
templateEnv = Environment(loader=FileSystemLoader(searchpath=str(Path(__file__).parent.resolve())))
args = dict(
project=project,
title=title,
module=module,
description=description,
author=author,
copyright=copyright,
version=version,
theme=theme,
docs_root=docs_root,
root=root,
cname=cname,
pages=pages,
use_autoapi=use_autoapi,
autoapi_ignore=autoapi_ignore,
source_dir=source_dir,
previous_versions=previous_versions,
use_breathe=use_breathe,
use_sphinx_rust=use_sphinx_rust,
use_sphinx_js=use_sphinx_js,
use_wiki=use_wiki,
**breathe_args,
**rust_args,
**js_args,
**wiki_args,
**configuration_args,
)
# adjust arguments if a callable is provided
if adjust_arguments:
args = adjust_arguments(args)
# load the templatized conf.py file
template = templateEnv.get_template("conf.py.j2")
# adjust the template if a callable is provided
if adjust_template:
template = adjust_template(template)
# Render
template = template.render(**args)
# dump to file
template_file = Path(td) / "conf.py"
template_file.write_text(template)
# write custom css and customjs
Path("docs/html/_static/styles").mkdir(parents=True, exist_ok=True)
Path("docs/html/_static/styles/custom.css").write_text(custom_css)
Path("docs/html/_static/js").mkdir(parents=True, exist_ok=True)
Path("docs/html/_static/js/custom.js").write_text(custom_js)
# append docs-specific ignores to gitignore
if Path(".gitignore").exists():
has_html_build_folder = False
has_index_md = False
with open(".gitignore", "r+") as fp:
for line in fp:
if "docs/html" in line:
has_html_build_folder = True
if "index.md" in line:
has_index_md = True
if not has_html_build_folder or not has_index_md:
fp.write("\n")
if not has_html_build_folder:
fp.write("docs/html\n")
if not has_index_md:
fp.write("index.md\n")
# yield folder path to sphinx build
yield td
[docs]
@contextmanager
def generate_wiki_configuration(
*,
project: str = "",
title: str = "",
module: str = "",
description: str = "",
author: str = "",
copyright: str = "",
version: str = "",
theme: str = "furo",
docs_root: str = "",
root: str = "",
cname: str = "",
pages: Optional[List] = None,
use_autoapi: Optional[bool] = None,
autoapi_ignore: Optional[List] = None,
custom_css: Optional[Path] = None,
custom_js: Optional[Path] = None,
config_base: str = "tool.yardang",
previous_versions: Optional[bool] = False,
adjust_arguments: Callable = None,
adjust_template: Callable = None,
):
"""Generate Sphinx configuration for GitHub Wiki markdown output.
A context manager similar to generate_docs_configuration, but configured
for building markdown output suitable for GitHub Wiki using
sphinx-markdown-builder.
This adds the sphinx_markdown_builder extension and sets appropriate
options for GitHub-flavored markdown output.
Args:
project: Project name. Falls back to ``[project].name`` or directory name.
title: Documentation title. Falls back to ``[tool.yardang].title`` or project name.
module: Python module name for autoapi. Falls back to project name with
hyphens replaced by underscores.
description: Project description for metadata.
author: Author name. Falls back to first entry in ``[project].authors``.
copyright: Copyright string. Falls back to author name.
version: Version string. Falls back to ``[project].version``.
theme: Sphinx theme name. Defaults to ``"furo"``.
docs_root: Base URL for hosted documentation. Used for canonical URLs.
root: Path to README or index file to use as documentation root.
cname: Custom domain name for GitHub Pages CNAME file.
pages: List of page paths to include in the toctree.
use_autoapi: Whether to use sphinx-autoapi for Python API docs.
custom_css: Path to custom CSS file.
custom_js: Path to custom JavaScript file.
config_base: Base key in pyproject.toml for configuration.
previous_versions: Whether to generate previous versions documentation.
adjust_arguments: Callback to modify template arguments before rendering.
adjust_template: Callback to modify the Jinja2 template before rendering.
Yields:
tuple: (config_dir, wiki_args) where config_dir is the path to the directory
containing the generated conf.py file, and wiki_args is a dict with
wiki configuration for post-processing.
"""
def add_markdown_builder(args):
# Enable wiki mode
args["use_wiki"] = True
# Call original adjust_arguments if provided
if adjust_arguments:
args = adjust_arguments(args)
return args
with generate_docs_configuration(
project=project,
title=title,
module=module,
description=description,
author=author,
copyright=copyright,
version=version,
theme=theme,
docs_root=docs_root,
root=root,
cname=cname,
pages=pages,
use_autoapi=use_autoapi,
autoapi_ignore=autoapi_ignore,
custom_css=custom_css,
custom_js=custom_js,
config_base=config_base,
previous_versions=previous_versions,
adjust_arguments=add_markdown_builder,
adjust_template=adjust_template,
) as config_dir:
# Read wiki args from config
wiki_config_base = f"{config_base}.wiki"
wiki_args = {
"wiki_output_dir": get_config(section="output-dir", base=wiki_config_base) or "docs/wiki",
"wiki_generate_sidebar": get_config(section="generate-sidebar", base=wiki_config_base),
"wiki_generate_footer": get_config(section="generate-footer", base=wiki_config_base),
"wiki_fix_links": get_config(section="fix-links", base=wiki_config_base),
"wiki_footer_docs_url": get_config(section="footer-docs-url", base=wiki_config_base) or "",
"wiki_footer_repo_url": get_config(section="footer-repo-url", base=wiki_config_base) or "",
}
# Apply defaults
if wiki_args["wiki_generate_sidebar"] is None:
wiki_args["wiki_generate_sidebar"] = True
if wiki_args["wiki_generate_footer"] is None:
wiki_args["wiki_generate_footer"] = True
if wiki_args["wiki_fix_links"] is None:
wiki_args["wiki_fix_links"] = True
yield config_dir, wiki_args