Source code for stouputils.applications.automatic_docs

""" Sphinx documentation generation utilities.

This module provides a comprehensive set of utilities for automatically generating
and managing Sphinx documentation for Python projects. It handles the creation
of configuration files, index pages, version management, and HTML generation.

Example of usage:

.. code-block:: python

    import stouputils as stp
    from stouputils.applications import automatic_docs

    if __name__ == "__main__":
        automatic_docs.update_documentation(
            root_path=stp.get_root_path(__file__, go_up=1),
            project="stouputils", 
            author="Stoupy",
            copyright="2025, Stoupy",
            html_logo="https://avatars.githubusercontent.com/u/35665974",
            html_favicon="https://avatars.githubusercontent.com/u/35665974",
            github_user="Stoupy51",
            github_repo="stouputils",
            version="1.2.0",
            skip_undocumented=True,
        )

.. image:: https://raw.githubusercontent.com/Stoupy51/stouputils/refs/heads/main/assets/applications/automatic_docs.gif
  :alt: stouputils automatic_docs examples

Example of GitHub Actions workflow:

.. code-block:: yaml

  name: documentation

  on: 
    push:
      tags:
        - 'v*'
    workflow_dispatch:

  permissions:
    contents: write

  jobs:
    docs:
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v4
        - uses: actions/setup-python@v5
        - name: Install dependencies
          run: |
            pip install stouputils
        - name: Build version docs
          run: |
            python scripts/create_docs.py ${GITHUB_REF#refs/tags/v}
        - name: Deploy to GitHub Pages
          uses: peaceiris/actions-gh-pages@v3
          with:
            publish_branch: gh-pages
            github_token: ${{ secrets.GITHUB_TOKEN }}
            publish_dir: docs/build/html
            keep_files: true
            force_orphan: false
"""
# Imports
import os
import shutil
import subprocess
import sys
from typing import Any, Callable

from ..io import clean_path, super_open, super_json_dump
from ..decorators import handle_error, simple_cache
from ..continuous_delivery import version_to_float
from ..print import info


[docs] def get_sphinx_conf_content( project: str, project_dir: str, author: str, current_version: str, copyright: str, html_logo: str, html_favicon: str, github_user: str, github_repo: str, version_list: list[str] | None = None, skip_undocumented: bool = True, ) -> str: """ Get the content of the Sphinx configuration file. Args: project (str): Name of the project project_dir (str): Path to the project directory author (str): Author of the project current_version (str): Current version copyright (str): Copyright information html_logo (str): URL to the logo html_favicon (str): URL to the favicon github_user (str): GitHub username github_repo (str): GitHub repository name version_list (list[str] | None): List of versions. Defaults to None skip_undocumented (bool): Whether to skip undocumented members. Defaults to True Returns: str: Content of the Sphinx configuration file """ parent_of_project_dir: str = clean_path(os.path.dirname(project_dir)) conf_content: str = f""" # Imports import sys from typing import Any # Add project_dir directory to Python path for module discovery sys.path.insert(0, "{parent_of_project_dir}") # Project information project: str = "{project}" copyright: str = "{copyright}" author: str = "{author}" release: str = "{current_version}" # General configuration extensions: list[str] = [ "sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx.ext.viewcode", "sphinx.ext.githubpages", "sphinx.ext.intersphinx", "furo.sphinxext", "m2r2", ] templates_path: list[str] = ["_templates"] exclude_patterns: list[str] = [] # HTML output options html_theme: str = "furo" html_static_path: list[str] = ["_static"] html_logo: str = "{html_logo}" html_title: str = "{project}" html_favicon: str = "{html_favicon}" # Theme options html_theme_options: dict[str, Any] = {{ "light_css_variables": {{ "color-brand-primary": "#2980B9", "color-brand-content": "#2980B9", "color-admonition-background": "#E8F0F8", }}, "dark_css_variables": {{ "color-brand-primary": "#56B4E9", "color-brand-content": "#56B4E9", "color-admonition-background": "#1F262B", }}, "sidebar_hide_name": False, "navigation_with_keys": True, "announcement": "This is the latest documentation of {project}", "footer_icons": [ {{ "name": "GitHub", "url": "https://github.com/{github_user}/{github_repo}", "html": "<i class='fab fa-github-square'></i>", "class": "", }}, ], }} """ # Create base html_context dictionary html_context: dict[str, Any] = { "display_github": True, "github_user": github_user, "github_repo": github_repo, "github_version": "main", "conf_py_path": "/docs/source/", "source_suffix": [".rst", ".md"], "default_mode": "dark", } # Add version selector if versions are provided if version_list and current_version: html_context.update({ "versions": version_list, "current_version": current_version, }) html_context_str: str = super_json_dump(html_context, max_level=1).replace("true", "True").replace("false", "False") conf_content += f""" html_context = {html_context_str} # Autodoc settings autodoc_default_options: dict[str, bool | str] = {{ "members": True, "member-order": "bysource", "special-members": False, "undoc-members": False, "private-members": True, "show-inheritance": True, "ignore-module-all": True, "exclude-members": "__weakref__", }} # Tell autodoc to prefer source code over installed package autodoc_mock_imports = [] always_document_param_types = True add_module_names = False """ if skip_undocumented: conf_content += """ # Only document items with docstrings def skip_undocumented(app: Any, what: str, name: str, obj: Any, skip: bool, *args: Any, **kwargs: Any) -> bool: if not obj.__doc__: return True return skip def setup(app: Any) -> None: app.connect("autodoc-skip-member", skip_undocumented) """ return conf_content
[docs] @simple_cache() def get_versions_from_github(github_user: str, github_repo: str) -> list[str]: """ Get list of versions from GitHub gh-pages branch. Args: github_user (str): GitHub username github_repo (str): GitHub repository name Returns: list[str]: List of versions, with 'latest' as first element """ import requests version_list: list[str] = [] try: response = requests.get(f"https://api.github.com/repos/{github_user}/{github_repo}/contents?ref=gh-pages") if response.status_code == 200: contents = response.json() version_list = ["latest"] + sorted( [d["name"].replace("v", "") for d in contents if d["type"] == "dir" and d["name"].startswith("v")], key=version_to_float, reverse=True ) except Exception as e: info(f"Failed to get versions from GitHub: {e}") version_list = ["latest"] return version_list
[docs] def markdown_to_rst(markdown_content: str) -> str: """ Convert markdown content to RST format. Args: markdown_content (str): Markdown content Returns: str: RST content """ if not markdown_content: return "" # Convert markdown to RST and return it import m2r2 # type: ignore return m2r2.convert(markdown_content) # type: ignore
[docs] def generate_index_rst( readme_path: str, index_path: str, project: str, github_user: str, github_repo: str, get_versions_function: Callable[[str, str], list[str]] = get_versions_from_github, ) -> None: """ Generate index.rst from README.md content. Args: readme_path (str): Path to the README.md file index_path (str): Path where index.rst should be created project (str): Name of the project github_user (str): GitHub username github_repo (str): GitHub repository name get_versions_function (Callable[[str, str], list[str]]): Function to get versions from GitHub """ # Read README content with open(readme_path, "r", encoding="utf-8") as f: readme_content: str = f.read() # Generate version selector version_selector: str = "\n\n**Versions**: " # Get versions from GitHub version_list: list[str] = get_versions_function(github_user, github_repo) # Create version links version_links: list[str] = [] for version in version_list: if version == "latest": version_links.append("`latest <../latest/index.html>`_") else: version_links.append(f"`v{version} <../v{version}/index.html>`_") version_selector += ", ".join(version_links) # Generate module documentation section project_module: str = project.lower() module_docs: str = f""" .. toctree:: :maxdepth: 10 modules/{project_module} """ # Convert markdown to RST rst_content: str = f""" 🛠️ Welcome to {project.capitalize()} Documentation {'=' * 100} {version_selector} {markdown_to_rst(readme_content)} 📖 Module Documentation {'-' * 100} {module_docs} """ # Write the RST file with open(index_path, "w", encoding="utf-8") as f: f.write(rst_content)
[docs] def generate_documentation( source_dir: str, modules_dir: str, project_dir: str, build_dir: str, ) -> None: """ Generate documentation using Sphinx. Args: source_dir (str): Source directory modules_dir (str): Modules directory project_dir (str): Project directory build_dir (str): Build directory """ # Generate module documentation using sphinx-apidoc subprocess.run([ sys.executable, "-m", "sphinx.ext.apidoc", "-o", modules_dir, "-f", "-e", "-M", "--no-toc", "-P", "--implicit-namespaces", "--module-first", project_dir, ], check=True) # Build HTML documentation subprocess.run([ sys.executable, "-m", "sphinx", "-b", "html", "-a", source_dir, build_dir, ], check=True)
[docs] def generate_redirect_html(filepath: str) -> None: """ Generate HTML content for redirect page. Args: filepath (str): Path to the file where the HTML content should be written """ with super_open(filepath, "w", encoding="utf-8") as f: f.write("""<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="refresh" content="0;url=./latest/"> <title>Redirecting...</title> </head> <body> <p>If you are not redirected automatically, <a href="./latest/">click here</a>.</p> </body> </html> """)
[docs] @handle_error() def update_documentation( root_path: str, project: str, project_dir: str = "", author: str = "Author", copyright: str = "2025, Author", html_logo: str = "", html_favicon: str = "", github_user: str = "", github_repo: str = "", version: str | None = None, skip_undocumented: bool = True, get_versions_function: Callable[[str, str], list[str]] = get_versions_from_github, generate_index_function: Callable[..., None] = generate_index_rst, generate_docs_function: Callable[..., None] = generate_documentation, generate_redirect_function: Callable[[str], None] = generate_redirect_html, get_conf_content_function: Callable[..., str] = get_sphinx_conf_content ) -> None: """ Update the Sphinx documentation. Args: root_path (str): Root path of the project project (str): Name of the project project_dir (str): Path to the project directory (to be used with generate_docs_function) author (str): Author of the project copyright (str): Copyright information html_logo (str): URL to the logo html_favicon (str): URL to the favicon github_user (str): GitHub username github_repo (str): GitHub repository name version (str | None): Version to build documentation for (e.g. "1.0.0", defaults to "latest") skip_undocumented (bool): Whether to skip undocumented members. Defaults to True get_versions_function (Callable[[str, str], list[str]]): Function to get versions from GitHub generate_index_function (Callable[..., None]): Function to generate index.rst generate_docs_function (Callable[..., None]): Function to generate documentation generate_redirect_function (Callable[[str], None]): Function to create redirect file get_conf_content_function (Callable[..., str]): Function to get Sphinx conf.py content """ # Setup paths root_path = clean_path(root_path) docs_dir: str = f"{root_path}/docs" source_dir: str = f"{docs_dir}/source" modules_dir: str = f"{source_dir}/modules" static_dir: str = f"{source_dir}/_static" templates_dir: str = f"{source_dir}/_templates" html_dir: str = f"{docs_dir}/build/html" # Remove "v" from version if it is a string (just in case) version = version.replace("v", "") if isinstance(version, str) else version # Modify build directory if version is specified latest_dir: str = f"{html_dir}/latest" build_dir: str = latest_dir if not version else f"{html_dir}/v{version}" # Create directories if they don't exist for dir in [modules_dir, static_dir, templates_dir]: os.makedirs(dir, exist_ok=True) # Generate index.rst from README.md readme_path: str = f"{root_path}/README.md" index_path: str = f"{source_dir}/index.rst" generate_index_function( readme_path=readme_path, index_path=index_path, project=project, github_user=github_user, github_repo=github_repo, get_versions_function=get_versions_function, ) # Clean up old module documentation if os.path.exists(modules_dir): shutil.rmtree(modules_dir) os.makedirs(modules_dir) # Get versions and current version for conf.py version_list: list[str] = get_versions_function(github_user, github_repo) current_version: str = version if version else "latest" # Generate conf.py conf_path: str = f"{source_dir}/conf.py" conf_content: str = get_conf_content_function( project=project, project_dir=project_dir, author=author, current_version=current_version, copyright=copyright, html_logo=html_logo, html_favicon=html_favicon, github_user=github_user, github_repo=github_repo, version_list=version_list, skip_undocumented=skip_undocumented, ) with open(conf_path, "w", encoding="utf-8") as f: f.write(conf_content) # Generate documentation generate_docs_function( source_dir=source_dir, modules_dir=modules_dir, project_dir=project_dir if project_dir else f"{root_path}/{project}", build_dir=build_dir, ) # Add index.html to the build directory that redirects to the latest version generate_redirect_function(f"{html_dir}/index.html") # If version is specified, copy the build directory to latest too # This is useful for GitHub Actions to prevent re-building the documentation from scratch without the version if version: if os.path.exists(latest_dir): shutil.rmtree(latest_dir) shutil.copytree(build_dir, latest_dir, dirs_exist_ok=True) info(f"Documentation updated successfully!") info(f"You can view the documentation by opening {build_dir}/index.html")