Source code for stouputils.applications.automatic_docs.zensical

""" Zensical documentation generation utilities.

This module provides a comprehensive set of utilities for automatically generating
and managing documentation for Python projects using **Zensical** (a modern static
site generator based on MkDocs Material) and **mkdocstrings** for API reference
generation from docstrings.

It handles the creation of configuration files, index pages, API reference 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.zensical_docs(
            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,
        )
"""
# Imports
import os
import shutil
import subprocess
import sys
from collections.abc import Callable

from ...decorators import LogLevels, handle_error
from ...io.path import clean_path, super_open
from ...print.message import info, warning
from .common import check_base_dependencies, download_asset, generate_redirect_html, generate_version_selector, get_versions_from_github


# Functions
[docs] def get_zensical_config_content( project: str, project_dir: str, docs_dir: str, site_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, api_pages: list[tuple[str, str]] | None = None, ) -> str: """ Get the content of the Zensical configuration file (zensical.toml). Args: project (str): Name of the project project_dir (str): Path to the project directory docs_dir (str): Path to the docs source directory (relative to root) site_dir (str): Path to the build output directory (relative to root) author (str): Author of the project current_version (str): Current version copyright (str): Copyright information html_logo (str): Path to the logo image (relative to docs_dir) html_favicon (str): Path to the favicon image (relative to docs_dir) 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 api_pages (list[tuple[str, str]] | None): List of (module_name, md_filename) tuples for nav Returns: str: Content of the Zensical configuration file (TOML) """ parent_of_project_dir: str = clean_path(os.path.dirname(project_dir)) # Build repo URL repo_url: str = f"https://github.com/{github_user}/{github_repo}" if github_user and github_repo else "" repo_name: str = f"{github_user}/{github_repo}" if github_user and github_repo else "" # Project section config: str = f"""[project] site_name = "{project}" site_description = "{project} documentation — v{current_version}" site_author = "{author}" copyright = "Copyright © {copyright}" docs_dir = "{docs_dir}" site_dir = "{site_dir}" """ if repo_url: config += f'repo_url = "{repo_url}"\n' config += f'repo_name = "{repo_name}"\n' config += 'extra_css = ["_static/custom.css"]\n' # Navigation if api_pages: nav_items: list[str] = [] for module_name, md_filename in api_pages: nav_items.append(f' {{ "{module_name}" = "api/{md_filename}" }}') api_nav: str = ",\n".join(nav_items) config += f""" nav = [ {{ "Home" = "index.md" }}, {{ "API Reference" = [ {api_nav}, ] }}, ] """ # Theme section config += """ [project.theme] language = "en" """ if html_favicon: config += f'favicon = "{html_favicon}"\n' if html_logo: config += f'logo = "{html_logo}"\n' config += """ features = [ "content.code.copy", "content.code.annotate", "content.code.select", "content.tooltips", "navigation.footer", "navigation.instant", "navigation.instant.prefetch", "navigation.sections", "navigation.top", "navigation.path", "navigation.indexes", "search.highlight", ] [[project.theme.palette]] scheme = "slate" toggle.icon = "lucide/sun" toggle.name = "Switch to light mode" [[project.theme.palette]] scheme = "default" toggle.icon = "lucide/moon" toggle.name = "Switch to dark mode" """ # mkdocstrings plugin configuration show_if_no_docstring: str = "false" if skip_undocumented else "true" config += f""" [project.plugins.mkdocstrings.handlers.python] paths = ["{parent_of_project_dir}"] [project.plugins.mkdocstrings.handlers.python.options] docstring_style = "google" show_source = true members_order = "source" show_root_heading = true show_symbol_type_heading = true show_symbol_type_toc = true show_if_no_docstring = {show_if_no_docstring} inherited_members = true merge_init_into_class = true group_by_category = true """ return config
[docs] def generate_index_md( readme_path: str, index_path: str, project: str, github_user: str, github_repo: str, get_versions_function: Callable[[str, str, int], list[str]] = get_versions_from_github, recent_minor_versions: int = 2, ) -> None: """ Generate ``index.md`` from README.md content. This keeps the README content as Markdown and creates the home page for the Zensical documentation. Navigation to API docs is handled by the Zensical config (nav or auto-discovery). Args: readme_path (str): Path to the README.md file index_path (str): Path where index.md 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, int], list[str]]): Function to get versions from GitHub recent_minor_versions (int): Number of recent minor versions to show all patches for. Defaults to 2 """ # Read README content with open(readme_path, encoding="utf-8") as f: readme_content: str = f.read() # Generate version selector (markdown links) version_selector: str = generate_version_selector( github_user=github_user, github_repo=github_repo, get_versions_function=get_versions_function, recent_minor_versions=recent_minor_versions, ) # Build final markdown content md_content: str = f"""# Welcome to {project.capitalize()} Documentation {version_selector} {readme_content} """ # Write the Markdown file with open(index_path, "w", encoding="utf-8") as f: f.write(md_content)
[docs] def generate_api_pages( api_dir: str, project: str, project_dir: str, ) -> list[tuple[str, str]]: """ Walk the project directory and generate Markdown pages with ``:::`` directives for mkdocstrings API documentation. Args: api_dir (str): Directory where API markdown pages should be written project (str): Name of the project (package name) project_dir (str): Path to the project directory (Python package root) Returns: list[tuple[str, str]]: List of (module_name, md_filename) tuples """ pages: list[tuple[str, str]] = [] project_dir = clean_path(project_dir) parent_dir: str = os.path.dirname(project_dir) os.makedirs(api_dir, exist_ok=True) for root, dirs, files in os.walk(project_dir): # Skip __pycache__ and sort directories for consistent ordering dirs[:] = sorted(d for d in dirs if d != "__pycache__") # Compute the Python module name from path rel_path: str = os.path.relpath(root, parent_dir).replace(os.sep, "/") module_name: str = rel_path.replace("/", ".") # Only process Python packages (directories with __init__.py) if "__init__.py" not in files: continue # Create page for the package itself md_filename: str = f"{module_name}.md" md_path: str = f"{api_dir}/{md_filename}" with open(md_path, "w", encoding="utf-8") as f: f.write(f"# {module_name}\n\n::: {module_name}\n") pages.append((module_name, md_filename)) # Create pages for individual submodules (non-__init__ .py files) for fname in sorted(files): if fname.endswith(".py") and fname != "__init__.py": sub_module: str = fname[:-3] full_module: str = f"{module_name}.{sub_module}" sub_md_filename: str = f"{full_module}.md" sub_md_path: str = f"{api_dir}/{sub_md_filename}" with open(sub_md_path, "w", encoding="utf-8") as f: f.write(f"# {full_module}\n\n::: {full_module}\n") pages.append((full_module, sub_md_filename)) return pages
[docs] def generate_documentation( config_path: str, root_path: str, ) -> None: """ Generate documentation using Zensical. Args: config_path (str): Path to the zensical.toml configuration file root_path (str): Root path of the project (working directory for the build) """ # Find the zensical executable zensical_cmd: str | None = shutil.which("zensical") if not zensical_cmd: raise RuntimeError( "'zensical' command not found in PATH. " "Install it with: pip install zensical" ) # Run zensical build info(f"Running: {zensical_cmd} build -f {config_path} --clean") result: subprocess.CompletedProcess[bytes] = subprocess.run( [zensical_cmd, "build", "-f", config_path, "--clean"], cwd=root_path, stdout=sys.stdout, stderr=sys.stderr, ) if result.returncode != 0: raise RuntimeError(f"Zensical build failed (exit code {result.returncode})")
[docs] @handle_error(error_log=LogLevels.WARNING_TRACEBACK) def zensical_docs( root_path: str, project: str, project_dir: str = "", author: str = "Author", copyright: str = "2025, Author", html_logo: str = "", html_favicon: str = "", html_theme: str = "", github_user: str = "", github_repo: str = "", version: str | None = None, skip_undocumented: bool = True, recent_minor_versions: int = 2, get_versions_function: Callable[[str, str, int], list[str]] = get_versions_from_github, generate_index_function: Callable[..., None] = generate_index_md, generate_api_pages_function: Callable[..., list[tuple[str, str]]] = generate_api_pages, generate_docs_function: Callable[..., None] = generate_documentation, generate_redirect_function: Callable[[str], None] = generate_redirect_html, get_config_content_function: Callable[..., str] = get_zensical_config_content, ) -> None: """ Update the documentation using Zensical and mkdocstrings. 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 or path to the logo image html_favicon (str): URL or path to the favicon image html_theme (str): Unused (kept for backward compatibility) 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 recent_minor_versions (int): Number of recent minor versions to show all patches for. Defaults to 2 get_versions_function (Callable[[str, str, int], list[str]]): Function to get versions from GitHub generate_index_function (Callable[..., None]): Function to generate index.md generate_api_pages_function (Callable[..., list[tuple[str, str]]]): Function to generate API pages generate_docs_function (Callable[..., None]): Function to generate documentation generate_redirect_function (Callable[[str], None]): Function to create redirect file get_config_content_function (Callable[..., str]): Function to get Zensical config content """ if html_theme: warning("The 'html_theme' parameter is not used in Zensical docs generation (it uses its own built-in theme).") check_base_dependencies() # Setup paths root_path = clean_path(root_path) docs_dir: str = f"{root_path}/docs" source_dir: str = f"{docs_dir}/source" api_dir: str = f"{source_dir}/api" static_dir: str = f"{source_dir}/_static" html_dir: str = f"{docs_dir}/build/html" # Resolve project directory effective_project_dir: str = project_dir if project_dir else f"{root_path}/{project}" # 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 for d in [api_dir, static_dir]: os.makedirs(d, exist_ok=True) # Download logo and favicon if they are URLs logo_ref: str = html_logo favicon_ref: str = html_favicon if html_logo and html_logo.startswith("http"): local_logo: str = f"{static_dir}/logo.png" download_asset(html_logo, local_logo) logo_ref = "_static/logo.png" if html_favicon and html_favicon.startswith("http"): local_favicon: str = f"{static_dir}/favicon.png" download_asset(html_favicon, local_favicon) favicon_ref = "_static/favicon.png" # Create custom CSS custom_css_path: str = f"{static_dir}/custom.css" with super_open(custom_css_path, "w") as f: f.write(""" /* Custom CSS for documentation */ /* Gradient animation keyframes */ @keyframes shine-slide { 0% { background-position: -200% center; } 100% { background-position: 200% center; } } /* On hover animation for links */ a:hover, a:hover span { background: linear-gradient( 110deg, currentColor 0%, currentColor 40%, white 50%, currentColor 60%, currentColor 100% ); background-size: 200% 100%; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; animation: shine-slide 3.5s linear infinite; } """) # Generate index.md from README.md readme_path: str = f"{root_path}/README.md" index_path: str = f"{source_dir}/index.md" 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, recent_minor_versions=recent_minor_versions, ) # Clean up old API documentation and regenerate if os.path.exists(api_dir): shutil.rmtree(api_dir) os.makedirs(api_dir, exist_ok=True) # Generate API pages with ::: directives for mkdocstrings api_pages: list[tuple[str, str]] = generate_api_pages_function( api_dir=api_dir, project=project, project_dir=effective_project_dir, ) info(f"Generated {len(api_pages)} API documentation pages") # Get versions and current version for config version_list: list[str] = get_versions_function(github_user, github_repo, recent_minor_versions) current_version: str = version if version else "latest" # Generate zensical.toml config # Paths in zensical.toml are relative to the config file location (root_path) relative_docs_dir: str = os.path.relpath(source_dir, root_path).replace(os.sep, "/") relative_site_dir: str = os.path.relpath(build_dir, root_path).replace(os.sep, "/") config_content: str = get_config_content_function( project=project, project_dir=effective_project_dir, docs_dir=relative_docs_dir, site_dir=relative_site_dir, author=author, current_version=current_version, copyright=copyright, html_logo=logo_ref, html_favicon=favicon_ref, github_user=github_user, github_repo=github_repo, version_list=version_list, skip_undocumented=skip_undocumented, api_pages=api_pages, ) config_path: str = f"{root_path}/zensical.toml" with open(config_path, "w", encoding="utf-8") as f: f.write(config_content) # Build documentation with Zensical generate_docs_function( config_path=config_path, root_path=root_path, ) # 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 if version: if os.path.exists(latest_dir): shutil.rmtree(latest_dir) shutil.copytree(build_dir, latest_dir, dirs_exist_ok=True) info("Documentation updated successfully!") info(f"You can view the documentation by opening {build_dir}/index.html")