├── .gitignore ├── requirements.txt ├── mkdocs_autoapi ├── __init__.py ├── literate_nav │ ├── __init__.py │ ├── exceptions.py │ ├── globber.py │ ├── resolve.py │ └── parser.py ├── section_index │ ├── __init__.py │ ├── section_page.py │ └── rewrite.py ├── generate_files │ ├── __init__.py │ ├── nav.py │ └── editor.py ├── logging.py ├── autoapi.py └── plugin.py ├── .readthedocs.yaml ├── .pre-commit-config.yaml ├── mkdocs.yml ├── LICENSE ├── .github └── workflows │ ├── publish-to-pypi.yml │ └── create-release.yml ├── docs ├── index.md └── usage.md ├── CHANGELOG.md ├── pyproject.toml ├── README.md └── CONTRIBUTING.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | site/ 3 | dist/ 4 | *.egg-info/ -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs>=1.4.0 2 | mkdocstrings[python]>=0.19.0 3 | pre-commit>=3.5.0 4 | ruff>=0.6.2 5 | -------------------------------------------------------------------------------- /mkdocs_autoapi/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for the mkdocs_autoapi module. 2 | 3 | As I work through the build, I'll update the documentation for this module. 4 | """ 5 | -------------------------------------------------------------------------------- /mkdocs_autoapi/literate_nav/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for the literate_nav module. 2 | 3 | As I work through the build, I'll update the documentation for this module. 4 | """ 5 | -------------------------------------------------------------------------------- /mkdocs_autoapi/section_index/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for the section_index module. 2 | 3 | As I work through the build, I'll update the documentation for this module. 4 | """ 5 | -------------------------------------------------------------------------------- /mkdocs_autoapi/literate_nav/exceptions.py: -------------------------------------------------------------------------------- 1 | """Literate nav exceptions.""" 2 | 3 | 4 | class LiterateNavError(Exception): 5 | """An error occurred while building a literate nav.""" 6 | 7 | pass 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-lts-latest 5 | tools: 6 | python: "3.8" 7 | 8 | mkdocs: 9 | configuration: mkdocs.yml 10 | 11 | python: 12 | install: 13 | - requirements: requirements.txt -------------------------------------------------------------------------------- /mkdocs_autoapi/generate_files/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for the generate_files module. 2 | 3 | As I work through the build, I'll update the documentation for this module. 4 | """ 5 | 6 | from .editor import FilesEditor 7 | from .nav import Nav as Nav 8 | 9 | 10 | def __getattr__(name: str): 11 | return getattr(FilesEditor.current(), name) 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # pre-commit configuration 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.6.0 6 | hooks: 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.6.2 12 | hooks: 13 | - id: ruff 14 | args: [--fix] 15 | - id: ruff-format 16 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: "mkdocs-autoapi" 2 | site_description: "MkDocs plugin providing automatic API reference generation" 3 | site_author: "Jacob Ayers" 4 | repo_url: "https://github.com/jcayers20/mkdocs-autoapi" 5 | edit_uri: "edit/feature-readthedocs/docs/" 6 | 7 | markdown_extensions: 8 | - admonition 9 | - pymdownx.details 10 | - pymdownx.superfences 11 | - toc: 12 | permalink: "#" 13 | 14 | nav: 15 | - 'Home': 'index.md' 16 | - 'Usage': 'usage.md' 17 | 18 | theme: 19 | name: readthedocs 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jacob Ayers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mkdocs_autoapi/section_index/section_page.py: -------------------------------------------------------------------------------- 1 | """Section page definition.""" 2 | 3 | # built-in imports 4 | 5 | # third-party imports 6 | from mkdocs.structure.nav import Section 7 | from mkdocs.structure.pages import Page 8 | 9 | 10 | class SectionPage(Section, Page): 11 | """A page that is also a section.""" 12 | 13 | def __init__(self, title: str, file, config, children): 14 | """Initialize a SectionPage instance.""" 15 | Page.__init__(self, title=title, file=file, config=config) 16 | Section.__init__(self, title=title, children=children) 17 | self.is_section = self.is_page = True 18 | 19 | active = Page.active # type: ignore[assignment] 20 | 21 | def __repr__(self): 22 | """Return a string representation of the SectionPage instance.""" 23 | result = Page.__repr__(self) 24 | if not result.startswith("Section"): 25 | result = "Section" + result 26 | return result 27 | 28 | def __eq__(self, other): 29 | """Check whether SectionPage instance is equal to another object.""" 30 | return object.__eq__(self, other) 31 | 32 | def __ne__(self, other): 33 | """Check whether SectionPage instance is not equal to another object.""" 34 | return not (self == other) 35 | 36 | def __hash__(self): 37 | """Get the hash of the SectionPage instance.""" 38 | return object.__hash__(self) 39 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: '3.x' 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install build 24 | - name: Build package 25 | run: python -m build 26 | - name: Publish package 27 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 28 | with: 29 | user: __token__ 30 | password: ${{ secrets.PYPI_API_TOKEN }} 31 | - name: Close milestone 32 | env: 33 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 34 | run: | 35 | version=${{ github.event.release.tag_name }} 36 | milestone_number=$(curl -s -H "Authorization: token $GH_TOKEN" \ 37 | https://api.github.com/repos/${{ github.repository }}/milestones \ 38 | | jq -r ".[] | select(.title==\"$version\") | .number") 39 | if [ -n "$milestone_number" ]; then 40 | curl -s -X PATCH -H "Authorization: token $GH_TOKEN" \ 41 | -d '{"state":"closed"}' \ 42 | https://api.github.com/repos/${{ github.repository }}/milestones/$milestone_number 43 | else 44 | echo "Milestone $version not found" 45 | fi 46 | -------------------------------------------------------------------------------- /mkdocs_autoapi/logging.py: -------------------------------------------------------------------------------- 1 | """Logging utilities.""" 2 | 3 | import logging 4 | from typing import Any, MutableMapping, Tuple 5 | 6 | 7 | class AutoApiLogger(logging.LoggerAdapter): 8 | """A logger adapter to prefix messages with the originating package name.""" 9 | 10 | def __init__(self, prefix: str, logger: logging.Logger): 11 | """Initialize the object. 12 | 13 | Arguments: 14 | prefix: The string to insert in front of every message. 15 | logger: The logger instance. 16 | """ 17 | super().__init__(logger, {}) 18 | self.prefix = prefix 19 | 20 | def process( 21 | self, msg: str, kwargs: MutableMapping[str, Any] 22 | ) -> Tuple[str, Any]: 23 | """Process the message. 24 | 25 | Args: 26 | msg: 27 | The message. 28 | kwargs: 29 | Remaining arguments. 30 | 31 | Returns: 32 | The processed message. 33 | """ 34 | return f"{self.prefix}: {msg}", kwargs 35 | 36 | 37 | def get_logger(name: str) -> AutoApiLogger: 38 | """Return a logger for plugins. 39 | 40 | Arguments: 41 | name: The name to use with `logging.getLogger`. 42 | 43 | Returns: 44 | A logger configured to work well in MkDocs, 45 | prefixing each message with the plugin package name. 46 | """ 47 | logger = logging.getLogger(f"mkdocs.plugins.{name}") 48 | return AutoApiLogger( 49 | prefix=name.split(".", 1)[0], 50 | logger=logger, 51 | ) 52 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | create_release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: '3.x' 20 | 21 | - name: Extract version from pyproject.toml 22 | id: extract_version 23 | run: | 24 | version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' pyproject.toml) 25 | echo "VERSION=$version" >> $GITHUB_ENV 26 | 27 | - name: Get pull request description 28 | id: get_pr_description 29 | uses: actions/github-script@v6 30 | with: 31 | github-token: ${{ secrets.GH_TOKEN }} 32 | script: | 33 | const pr = await github.rest.pulls.list({ 34 | owner: context.repo.owner, 35 | repo: context.repo.repo, 36 | state: 'closed', 37 | base: 'main', 38 | sort: 'updated', 39 | direction: 'desc', 40 | per_page: 1 41 | }); 42 | const description = pr.data[0].body || 'No description provided'; 43 | return description; 44 | result-encoding: string 45 | 46 | - name: Create tag 47 | id: create_tag 48 | uses: rickstaa/action-create-tag@v1 49 | with: 50 | tag: "${{ env.VERSION }}" 51 | # message: "${{ steps.get_pr_description.outputs.result }}" 52 | github_token: ${{ secrets.GH_TOKEN }} 53 | 54 | - name: Create GitHub release 55 | uses: actions/create-release@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 58 | with: 59 | tag_name: "${{ env.VERSION }}" 60 | release_name: "${{ env.VERSION }}" 61 | body: "${{ steps.get_pr_description.outputs.result }}" 62 | draft: false 63 | prerelease: false 64 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # mkdocs-autoapi 2 | 3 | `mkdocs-autoapi` is a plugin for [MkDocs](https://www.mkdocs.org) that generates 4 | API documentation from your project's source code. The plugin leverages the 5 | functionality provided by [mkdocstrings](https://mkdocstrings.github.io/) and 6 | locates all Python modules in your project to create a set of reference pages. 7 | 8 | ## Installation 9 | 10 | ### Requirements 11 | 12 | `mkdocs-autoapi` requires the following: 13 | 14 | * Python version 3.6 or higher 15 | * MkDocs version 1.4.0 or higher 16 | * mkdocstrings version 0.19.0 or higher 17 | 18 | In addition, you must install an `mkdocstrings` 19 | [handler](https://mkdocstrings.github.io/usage/handlers/) for your project's 20 | programming language. 21 | 22 | !!! note 23 | Currently, Python and VBA handlers are supported. Support for additional 24 | programming languages (e.g., C, shell) is planned for future releases. 25 | See [Installation via `pip`](#installation-via-pip) for more details on how 26 | to install handlers along with `mkdocs-autoapi`. 27 | 28 | ### Installation via `pip` 29 | 30 | To install `mkdocs-autoapi` with `pip`: 31 | 32 | ```bash 33 | pip install mkdocs-autoapi 34 | ``` 35 | 36 | Extras are provided to support installation of `mkdocstrings` handlers: 37 | 38 | ```bash 39 | pip install mkdocs-autoapi[python] # new Python handler 40 | ``` 41 | 42 | ```bash 43 | pip install mkdocs-autoapi[python-legacy] # legacy Python handler 44 | ``` 45 | 46 | ```bash 47 | pip install mkdocs-autoapi[vba] # VBA handler 48 | ``` 49 | 50 | ## Basic Usage 51 | 52 | To get started using `mkdocs-autoapi`, add the following to `mkdocs.yml`: 53 | 54 | ```yaml title="mkdocs.yml" 55 | 56 | nav: 57 | - ... other navigation configuration ... 58 | - API Reference: autoapi/ 59 | - ... other navigation configuration ... 60 | 61 | plugins: 62 | - ... other plugin configuration ... 63 | - mkdocs-autoapi 64 | - mkdocstrings 65 | ``` 66 | 67 | For details on configuration and examples, see the [Usage](usage.md) section. 68 | -------------------------------------------------------------------------------- /mkdocs_autoapi/literate_nav/globber.py: -------------------------------------------------------------------------------- 1 | """Definiton of the globber class.""" 2 | 3 | # built-in imports 4 | import fnmatch 5 | import re 6 | from pathlib import PurePosixPath 7 | from typing import Iterator, Union 8 | 9 | # local imports 10 | from mkdocs.structure.files import Files 11 | 12 | 13 | class MkDocsGlobber: 14 | """Globber for MkDocs files.""" 15 | 16 | def __init__(self, files: Files): 17 | """Initialize an MkDocsGlobber object. 18 | 19 | Args: 20 | files: 21 | The MkDocs files object. 22 | """ 23 | self.files = {} 24 | self.dirs = {} 25 | self.index_dirs = {} 26 | 27 | for f in files: 28 | if not f.is_documentation_page(): 29 | continue 30 | 31 | path = PurePosixPath("/", f.src_uri) 32 | self.files[path] = True 33 | tail, head = path.parent, path.name 34 | 35 | if f.name == "index": 36 | self.index_dirs[tail] = path 37 | 38 | while True: 39 | self.dirs[tail] = True 40 | 41 | if not head: 42 | break 43 | 44 | tail, head = tail.parent, tail.name 45 | 46 | def isdir(self, path: str) -> bool: 47 | """Check if `path` is a directory.""" 48 | return PurePosixPath("/", path) in self.dirs 49 | 50 | def glob(self, pattern: str) -> Iterator[str]: 51 | """Glob `pattern`.""" 52 | pat_parts = PurePosixPath("/" + pattern).parts 53 | re_parts = [re.compile(fnmatch.translate(part)) for part in pat_parts] 54 | 55 | for collection in self.files, self.dirs: 56 | for path in collection: 57 | if len(path.parts) == len(re_parts): 58 | zipped = zip(path.parts, re_parts) 59 | next( 60 | zipped 61 | ) # Both path and pattern have a slash as their first part. 62 | if all(re_part.match(part) for part, re_part in zipped): 63 | yield str(path)[1:] 64 | 65 | def find_index(self, root: str) -> Union[str, None]: 66 | """Find the index file for `root`.""" 67 | root_path = PurePosixPath("/", root) 68 | if root_path in self.index_dirs: 69 | return str(self.index_dirs[root_path])[1:] 70 | return None 71 | -------------------------------------------------------------------------------- /mkdocs_autoapi/literate_nav/resolve.py: -------------------------------------------------------------------------------- 1 | """Logic to resolve directories in navigation.""" 2 | 3 | # built-in imports 4 | import os 5 | from typing import Optional, Tuple, Union 6 | 7 | # third-party imports 8 | import mkdocs.structure 9 | from mkdocs.structure.pages import Page 10 | 11 | # local imports 12 | from mkdocs_autoapi.generate_files.editor import Files 13 | from mkdocs_autoapi.literate_nav import parser 14 | from mkdocs_autoapi.literate_nav.globber import MkDocsGlobber 15 | 16 | 17 | def resolve_directories_in_nav( 18 | nav_data, 19 | files: Files, 20 | nav_file_name: str, 21 | implicit_index: bool, 22 | markdown_config: Optional[dict] = None, 23 | ): 24 | """Replace `directory/` references in MkDocs nav config. 25 | 26 | Directories, if found, are resolved by the rules of literate nav insertion: 27 | If it has a literate nav file, that is used. Otherwise, an implicit nav is 28 | generated. 29 | """ 30 | 31 | def get_nav_for_dir(path: str) -> Union[Tuple[str, str], None]: 32 | file = files.get_file_from_path(os.path.join(path, nav_file_name)) 33 | if not file: 34 | return None 35 | 36 | # Prevent the warning in case the user doesn't also end up including 37 | # this page in the final nav, maybe they want it only for the purpose of 38 | # feeding to this plugin. 39 | try: # MkDocs 1.5+ 40 | if file.inclusion.is_in_nav(): 41 | file.inclusion = ( 42 | mkdocs.structure.files.InclusionLevel.NOT_IN_NAV 43 | ) 44 | except AttributeError: 45 | # https://github.com/mkdocs/mkdocs/blob/ff0b726056/mkdocs/structure/nav.py#L113 46 | Page(None, file, {}) # type: ignore[arg-type] 47 | 48 | # https://github.com/mkdocs/mkdocs/blob/fa5aa4a26e/mkdocs/structure/pages.py#L120 49 | with open(file.abs_src_path, encoding="utf-8-sig") as f: 50 | return nav_file_name, f.read() 51 | 52 | globber = MkDocsGlobber(files) 53 | nav_parser = parser.NavParser( 54 | get_nav_for_dir, 55 | globber, 56 | implicit_index=implicit_index, 57 | markdown_config=markdown_config, 58 | ) 59 | 60 | result = None 61 | if not nav_data or get_nav_for_dir("."): 62 | result = nav_parser.markdown_to_nav() 63 | return result or nav_parser.resolve_yaml_nav(nav_data or []) 64 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on 6 | [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project 7 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## 0.4.1 - 2025-04-01 10 | 11 | [View Changes on GitHub](https://github.com/jcayers20/mkdocs-autoapi/compare/0.4.0...0.4.1) 12 | 13 | ### Bug Fixes 14 | 15 | - Fixed a bug where the plugin would crash if `nav` is not defined in 16 | `mkdocs.yml` but `autoapi_add_nav_entry` is not `False` (thanks @k4lizen for the 17 | catch!) 18 | 19 | ## 0.4.0 - 2025-02-05 20 | 21 | [View Changes on GitHub](https://github.com/jcayers20/mkdocs-autoapi/compare/0.3.2...0.4.0) 22 | 23 | ### Bug Fixes 24 | 25 | - Fixed `mkdocs` crash if both `repo_url` and `edit_uri` were provided in 26 | `mkdocs.yml` 27 | - Fixed navigation when using the `mkdocs` theme 28 | 29 | ### Features 30 | 31 | - Enabled VBA support 32 | 33 | ## 0.3.2 - 2024-11-01 34 | 35 | [View Changes on GitHub](https://github.com/jcayers20/mkdocs-autoapi/compare/0.3.1...0.3.2) 36 | 37 | ### Bug Fixes 38 | 39 | - Fixed incorrect optional dependency configuration 40 | 41 | ## 0.3.1 - 2024-10-03 42 | 43 | [View Changes on GitHub](https://github.com/jcayers20/mkdocs-autoapi/compare/0.3.0...0.3.1) 44 | 45 | ### Developer Support 46 | 47 | - Added GitHub Actions for automated package publishing and release/milestone 48 | management on push to the `main` branch 49 | 50 | ## 0.3.0 - 2024-09-30 51 | 52 | [View Changes on GitHub](https://github.com/jcayers20/mkdocs-autoapi/compare/0.2.1...0.3.0) 53 | 54 | ### Developer Support 55 | 56 | - Added pre-commit hooks for code formatting and linting 57 | - Added a development guide (`CONTRIBUTING.md`) to the documentation 58 | - Added some logging to the plugin to help with debugging 59 | 60 | ### Features 61 | 62 | - Renamed all existing configuration options to align with names used in 63 | `sphinx-autoapi`: 64 | - `project_root` to `autoapi_dir` 65 | - `exclude` to `autoapi_ignore` 66 | - `generate_local_output` to `autoapi_keep_files` 67 | - `output_dir` to `autoapi_root` 68 | - Added new configuration options based on `sphinx-autoapi`: 69 | - `autoapi_file_patterns`: Define which files to include in the auto-generated 70 | documentation 71 | - `autoapi_generate_api_docs`: Define whether to generate API documentation 72 | - `autoapi_add_nav_entry`: Define whether to add a navigation entry for the 73 | API documentation 74 | 75 | 76 | ## 0.2.1 - 2024-08-27 77 | 78 | [View Changes on GitHub](https://github.com/jcayers20/mkdocs-autoapi/compare/0.2.0...0.2.1) 79 | 80 | ### Bug Fixes 81 | 82 | - Fixed an ill-formed link in the requirements documentation 83 | - Fixed a typo in a function name (this function was not user-facing) 84 | 85 | 86 | ## 0.2.0 - 2024-08-26 87 | 88 | [View Changes on GitHub](https://github.com/jcayers20/mkdocs-autoapi/compare/0.1.6...0.2.0) 89 | 90 | ### Features 91 | 92 | - Added documentation 93 | - Added new configuration options: 94 | - `generate_local_output`: Define whether to create local copies of the 95 | auto-generated API documentation 96 | - `output_dir`: Define the directory in which the auto-generated API 97 | documentation will be stored 98 | - Various bug fixes and improvements 99 | 100 | 101 | ## 0.1.6 - 2024-06-06 102 | 103 | [View on GitHub](https://github.com/jcayers20/mkdocs-autoapi/tree/0.1.6) 104 | 105 | First working version of the plugin. 106 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "mkdocs-autoapi" 7 | version = "0.4.1" 8 | description = "MkDocs plugin providing automatic API reference generation" 9 | dependencies = [ 10 | "mkdocs>=1.4.0", 11 | "mkdocstrings>=0.19.0", 12 | ] 13 | readme = "README.md" 14 | requires-python = ">=3.6" 15 | authors = [ 16 | { name = "Jacob Ayers", email = "jcayers20@gmail.com" } 17 | ] 18 | keywords = ["mkdocs", "documentation", "api", "autoapi"] 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: MIT License", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.6", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | ] 32 | 33 | [project.entry-points.'mkdocs.plugins'] 34 | mkdocs-autoapi = 'mkdocs_autoapi.plugin:AutoApiPlugin' 35 | 36 | [project.optional-dependencies] 37 | python-legacy = ["mkdocstrings[python-legacy]>=0.19.0"] 38 | python = ["mkdocstrings[python]>=0.19.0"] 39 | vba = ["mkdocstrings-vba>=0.0.10"] 40 | 41 | [project.urls] 42 | Changelog = "https://github.com/jcayers20/mkdocs-autoapi/blob/main/CHANGELOG.md" 43 | Documentation = "https://mkdocs-autoapi.readthedocs.io/en/0.2.1/" 44 | Repository = "https://github.com/jcayers20/mkdocs-autoapi" 45 | Issues = "https://github.com/jcayers20/mkdocs-autoapi/issues" 46 | License = "https://github.com/jcayers20/mkdocs-autoapi/blob/main/LICENSE" 47 | 48 | [tool.ruff] 49 | # Exclude a variety of commonly ignored directories. 50 | exclude = [ 51 | ".bzr", 52 | ".direnv", 53 | ".eggs", 54 | ".git", 55 | ".git-rewrite", 56 | ".hg", 57 | ".ipynb_checkpoints", 58 | ".mypy_cache", 59 | ".nox", 60 | ".pants.d", 61 | ".pyenv", 62 | ".pytest_cache", 63 | ".pytype", 64 | ".ruff_cache", 65 | ".svn", 66 | ".tox", 67 | ".venv", 68 | ".vscode", 69 | "__pypackages__", 70 | "_build", 71 | "buck-out", 72 | "build", 73 | "dist", 74 | "node_modules", 75 | "site-packages", 76 | "venv", 77 | ] 78 | 79 | # Same as Black. 80 | line-length = 80 81 | indent-width = 4 82 | 83 | # Assume Python 3.8 84 | target-version = "py38" 85 | 86 | [tool.ruff.lint] 87 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 88 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 89 | # McCabe complexity (`C901`) by default. 90 | select = ["D", "E101", "E4", "E7", "E9", "F", "I"] 91 | ignore = [] 92 | 93 | # Allow fix for all enabled rules (when `--fix`) is provided. 94 | fixable = ["ALL"] 95 | unfixable = [] 96 | 97 | # Allow unused variables when underscore-prefixed. 98 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 99 | 100 | [tool.ruff.format] 101 | # Like Black, use double quotes for strings. 102 | quote-style = "double" 103 | 104 | # Like Black, indent with spaces, rather than tabs. 105 | indent-style = "space" 106 | 107 | # Like Black, respect magic trailing commas. 108 | skip-magic-trailing-comma = false 109 | 110 | # Like Black, automatically detect the appropriate line ending. 111 | line-ending = "auto" 112 | 113 | # Use Google docstring formatting convention 114 | [tool.ruff.lint.pydocstyle] 115 | convention = "google" 116 | -------------------------------------------------------------------------------- /mkdocs_autoapi/generate_files/nav.py: -------------------------------------------------------------------------------- 1 | """Logic used to implement Nav class. 2 | 3 | As I work through the build, I'll update the documentation for this module. 4 | """ 5 | 6 | import dataclasses 7 | import os 8 | from typing import Iterable, Mapping, Optional, Tuple, Union 9 | 10 | 11 | class Nav: 12 | """MkDocs navigation data structure.""" 13 | 14 | _markdown_special_characters = tuple("!#()*+-[\\]_`{}") 15 | """The set of characters that need to be escaped in Markdown.""" 16 | 17 | def __init__(self): 18 | """Initialize a Nav object.""" 19 | self._data = dict() 20 | 21 | @dataclasses.dataclass 22 | class Item: 23 | """Define a navigation item.""" 24 | 25 | level: int 26 | """The item's nesting level. Starts at 0.""" 27 | title: str 28 | """The item's title.""" 29 | filename: Optional[str] 30 | """The path the item links to. If None, item is section, not link.""" 31 | 32 | def __setitem__(self, keys: Union[str, Tuple[str, ...]], value: str): 33 | """Add file link into the nav, under the sequence of titles. 34 | 35 | For example, writing `nav["Foo", "Bar"] = "foo/bar.md"` would mean 36 | creating a nav: 37 | `{"Foo": {"Bar": "foo/bar.md"}}`. 38 | 39 | Then, writing `nav["Foo", "Another"] = "test.md"` would merge with the 40 | existing sections where possible: 41 | `{"Foo": {"Bar": "foo/bar.md", "Another": "test.md"}}`. 42 | 43 | `keys` here can be any non-empty sequence of strings, it's just that 44 | Python implicitly creates a tuple from the comma-separated items in 45 | those square brackets. 46 | """ 47 | if isinstance(keys, str): 48 | keys = (keys,) 49 | cur = self._data 50 | if not keys: 51 | raise ValueError( 52 | f"Navigation path must not be empty (got {keys!r})" 53 | ) 54 | for key in keys: 55 | if not isinstance(key, str): 56 | message = f"Navigation path must consist of strings, but got a {type(key)}" # noqa: E501 57 | raise TypeError(message) 58 | if not key: 59 | raise ValueError( 60 | f"Navigation name parts must not be empty (got {keys!r})" 61 | ) 62 | cur = cur.setdefault(key, {}) 63 | cur[None] = os.fspath(value) 64 | 65 | def items(self) -> Iterable[Item]: 66 | """Allows viewing the nav as a flattened sequence.""" 67 | return self._items(self._data, 0) 68 | 69 | @classmethod 70 | def _items(cls, data: Mapping, level: int) -> Iterable[Item]: 71 | for key, value in data.items(): 72 | if key is not None: 73 | yield cls.Item(level=level, title=key, filename=value.get(None)) 74 | yield from cls._items(value, level + 1) 75 | 76 | def build_literate_nav(self, indentation: int = 0) -> Iterable[str]: 77 | """Build a sequence of lines for a literate navigation file. 78 | 79 | Steps: 80 | 1. For each item in the navigation: 81 | 1.1. Get the title and filename of the item. 82 | 1.2. Escape the title if it starts with a markdown escape 83 | character. 84 | 1.3. If the item has a filename, format it as a Markdown 85 | link. 86 | 1.4. Yield the formatted line. 87 | 88 | Args: 89 | indentation: 90 | The number of spaces to indent the whole nav. Useful when the 91 | nav is a part of a larger file. Defaults to 0. 92 | 93 | Yields: 94 | The lines of the navigation file. 95 | 96 | See Also: 97 | [mkdocs-literate-nav](https://github.com/oprypin/mkdocs-literate-nav) 98 | """ 99 | # Step 1 100 | for item in self.items(): 101 | # Step 1.1 102 | title = item.title 103 | file = item.filename 104 | 105 | # Step 1.2 106 | if title.startswith(self._markdown_special_characters): 107 | title = f"\\{title}" 108 | 109 | # Step 1.3 110 | if item.filename is not None: 111 | line = f"[{title}]({file})" 112 | else: 113 | line = title 114 | 115 | # Step 1.4 116 | indent = " " * (indentation + (4 * item.level)) 117 | yield f"{indent}* {line}\n" 118 | -------------------------------------------------------------------------------- /mkdocs_autoapi/generate_files/editor.py: -------------------------------------------------------------------------------- 1 | """File editor logic - copied from mkdocs-gen-files.""" 2 | 3 | # built-in imports 4 | import collections 5 | import os 6 | import os.path 7 | import pathlib 8 | import shutil 9 | from typing import IO, MutableMapping, Optional, Union 10 | 11 | # third-party imports 12 | from mkdocs.config import load_config 13 | from mkdocs.config.defaults import MkDocsConfig 14 | from mkdocs.structure.files import File, Files 15 | 16 | 17 | def file_sort_key(f: File): 18 | """Sort key for file.""" 19 | parts = pathlib.PurePosixPath(f.src_uri).parts 20 | return tuple( 21 | chr(f.name != "index" if i == len(parts) - 1 else 2) + p 22 | for i, p in enumerate(parts) 23 | ) 24 | 25 | 26 | class FilesEditor: 27 | """Context manager for editing files in a MkDocs build.""" 28 | 29 | config: MkDocsConfig 30 | """The current MkDocs [config](https://www.mkdocs.org/user-guide/plugins/#config).""" 31 | directory: str 32 | """The base directory for `open()` ([docs_dir](https://www.mkdocs.org/user-guide/configuration/#docs_dir)).""" 33 | edit_paths: MutableMapping[str, Union[str, None]] 34 | 35 | def open( 36 | self, name: str, mode, buffering=-1, encoding=None, *args, **kwargs 37 | ) -> IO: 38 | """Open a file under `docs_dir` virtually. 39 | 40 | This function, for all intents and purposes, is just an `open()` which 41 | pretends that it is running under [docs_dir](https://www.mkdocs.org/user-guide/configuration/#docs_dir). 42 | Write operations don't affect the actual files when running as part of 43 | an MkDocs build, but they do become part of the site build. 44 | """ 45 | path = self._get_file(name, new="w" in mode) 46 | if encoding is None and "b" not in mode: 47 | encoding = "utf-8" 48 | return open(path, mode, buffering, encoding, *args, **kwargs) 49 | 50 | def _get_file(self, name: str, new: bool = False) -> str: 51 | """Get file path for `name`, creating it if necessary.""" 52 | new_f = File( 53 | name, 54 | src_dir=self.directory, 55 | dest_dir=self.config.site_dir, 56 | use_directory_urls=self.config.use_directory_urls, 57 | ) 58 | new_f.generated_by = "mkdocs-gen-files" # type: ignore[attr-defined] 59 | normname = pathlib.PurePath(name).as_posix() 60 | 61 | if new or normname not in self._files: 62 | os.makedirs(os.path.dirname(new_f.abs_src_path), exist_ok=True) 63 | self._files[normname] = new_f 64 | self.edit_paths.setdefault(normname, None) 65 | return new_f.abs_src_path 66 | 67 | f = self._files[normname] 68 | if f.abs_src_path != new_f.abs_src_path: 69 | os.makedirs(os.path.dirname(new_f.abs_src_path), exist_ok=True) 70 | self._files[normname] = new_f 71 | self.edit_paths.setdefault(normname, None) 72 | shutil.copyfile(f.abs_src_path, new_f.abs_src_path) 73 | return new_f.abs_src_path 74 | 75 | return f.abs_src_path 76 | 77 | def set_edit_path(self, name: str, edit_name: Union[str, None]) -> None: 78 | """Choose a file path to use for the edit URI of this file.""" 79 | self.edit_paths[pathlib.PurePath(name).as_posix()] = edit_name and str( 80 | edit_name 81 | ) 82 | 83 | def __init__( 84 | self, 85 | files: Files, 86 | config: MkDocsConfig, 87 | directory: Optional[str] = None, 88 | ): 89 | """Initialize a FilesEditor object.""" 90 | self._files: MutableMapping[str, File] = collections.ChainMap( 91 | {}, {f.src_uri: f for f in files} 92 | ) 93 | self.config = config 94 | if directory is None: 95 | directory = config.docs_dir 96 | self.directory = directory 97 | self.edit_paths = {} 98 | 99 | _current = None 100 | _default = None 101 | 102 | @classmethod 103 | def current(cls): 104 | """Get FilesEditor instance for current MkDocs build. 105 | 106 | If used as part of a MkDocs build (*gen-files* plugin), it's an instance 107 | using virtual files that feed back into the build. 108 | 109 | If not, this still tries to load the MkDocs config to find out the 110 | `docs_dir`, and then actually performs any file writes that happen via 111 | `.open()`. 112 | 113 | Warning: 114 | This is global (not thread-safe). 115 | """ 116 | if cls._current: 117 | return cls._current 118 | if not cls._default: 119 | config = load_config("mkdocs.yml") 120 | config.plugins.run_event("config", config) 121 | cls._default = FilesEditor(Files([]), config) 122 | return cls._default 123 | 124 | def __enter__(self): 125 | """Set current instance to this one.""" 126 | type(self)._current = self 127 | return self 128 | 129 | def __exit__(self, *exc): 130 | """Clear current instance.""" 131 | type(self)._current = None 132 | 133 | @property 134 | def files(self) -> Files: 135 | """Access current file structure. 136 | 137 | [Files]: https://github.com/mkdocs/mkdocs/blob/master/mkdocs/structure/files.py 138 | """ 139 | files = sorted(self._files.values(), key=file_sort_key) 140 | return Files(files) 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mkdocs-autoapi 2 | 3 | [![pypi version](https://img.shields.io/pypi/v/mkdocs-autoapi.svg)](https://pypi.org/project/mkdocs-autoapi/) 4 | [![docs](https://readthedocs.org/projects/mkdocs-autoapi/badge/?version=latest)](https://mkdocs-autoapi.readthedocs.io/en/latest/) 5 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 6 | 7 | 8 | ## Description 9 | 10 | `mkdocs-autoapi` is a MkDocs plugin that automatically generates API 11 | documentation from your project's source code. The idea for the plugin comes 12 | from this [recipe](https://mkdocstrings.github.io/recipes/#automatic-code-reference-pages) 13 | in the MkDocs documentation. 14 | 15 | ## Installation 16 | 17 | ### Requirements 18 | 19 | * Python version 3.6 or higher 20 | * MkDocs version 1.4.0 or higher 21 | * mkdocstrings version 0.19.0 or higher 22 | 23 | ### Installation via `pip` 24 | 25 | We recommend installing this package with `pip`: 26 | 27 | ```bash 28 | pip install mkdocs-autoapi 29 | ``` 30 | 31 | ## Usage 32 | 33 | ### Basic Usage 34 | 35 | To use the plugin, add the following configuration to your `mkdocs.yml` file: 36 | 37 | ```yaml 38 | plugins: 39 | - ... other plugin configuration ... 40 | - mkdocs-autoapi 41 | - mkdocstrings 42 | ``` 43 | 44 | ### Setting the Project Root 45 | 46 | By default, the plugin will use the current working directory as the project 47 | root. If you would like to use a different directory, you can specify a value 48 | in the `autoapi_dir` configuration option: 49 | 50 | ```yaml 51 | plugins: 52 | - ... other plugin configuration ... 53 | - mkdocs-autoapi: 54 | autoapi_dir: /path/to/autoapi/dir 55 | - mkdocstrings 56 | ``` 57 | 58 | ### Including and Ignoring Patterns 59 | 60 | You can ignore files and directories from the documentation by specifying a 61 | value in the `autoapi_ignore` configuration option. This option accepts a list 62 | of glob patterns. Note that the following patterns are always ignored: 63 | 64 | * `**/.venv/**/` 65 | * `**/venv/**/` 66 | 67 | Likewise, the `autoapi_file_patterns` configuration option allows for control of 68 | which files are included in the API reference. This option also accepts a list 69 | of glob patterns which are evaluated (recursively) relative to `autoapi_dir`. By 70 | default, all files with `.py` and `.pyi` extensions are included. 71 | 72 | As an example, suppose your project has the following structure: 73 | 74 | ```tree 75 | project/ 76 | docs/ 77 | index.md 78 | module/ 79 | __init__.py 80 | lorem.py 81 | ipsum.py 82 | dolor.py 83 | second_module/ 84 | __init__.py 85 | lorem.py 86 | sit.py 87 | amet.py 88 | venv/ 89 | mkdocs.yml 90 | README.md 91 | ``` 92 | 93 | To ignore all files named `lorem.py`, you can add the following configuration 94 | to your `mkdocs.yml` file: 95 | 96 | ```yaml 97 | plugins: 98 | - ... other plugin configuration ... 99 | - mkdocs-autoapi: 100 | autoapi_ignore: 101 | - "**/lorem.py" 102 | autoapi_file_patterns: 103 | - "*.py" 104 | - mkdocstrings 105 | ``` 106 | 107 | ## Disabling API Documentation Generation 108 | 109 | To disable API documentation generation, set the `autoapi_generate_api_docs` 110 | configuration option to `False`. This is useful when transitioning to manual 111 | documentation or when the API documentation is not needed. 112 | 113 | ## Including API Documentation in Navigation 114 | 115 | The inclusion of API documentation in the navigation can be controlled via the 116 | configuration option `autoapi_add_nav_entry`. This option accepts either a 117 | boolean value or a string. Behavior is as follows: 118 | 119 | * If `True`, then a section named "API Reference" will be added to the end of 120 | the navigation. 121 | * If `False`, then no section for the API documentation will be added. In this 122 | case, a manual link to the API documentation can be added to the navigation. 123 | * If a string, then a section with the specified name will be added to the end 124 | of the navigation. 125 | 126 | Example: To include the API documentation in the navigation under the section 127 | "Reference", add the following configuration to `mkdocs.yml`: 128 | 129 | ```yaml 130 | plugins: 131 | - ... other plugin configuration ... 132 | - mkdocs-autoapi: 133 | autoapi_add_nav_entry: Reference 134 | - mkdocstrings 135 | ``` 136 | 137 | Example: To disable the automatic addition of the API documentation to the 138 | navigation and add a manual link to the API documentation, add the following 139 | configuration to `mkdocs.yml`: 140 | 141 | ```yaml 142 | nav: 143 | - ... other navigation configuration ... 144 | - API: autoapi/ # target should be `autoapi_root` 145 | - ... other navigation configuration ... 146 | ``` 147 | 148 | More information on navigation configuration can be found in the 149 | [MkDocs User Guide](https://www.mkdocs.org/user-guide/configuration/#nav). 150 | 151 | ### Putting It All Together 152 | 153 | Again, consider the following project structure: 154 | 155 | ```tree 156 | project/ 157 | docs/ 158 | index.md 159 | module/ 160 | __init__.py 161 | lorem.py 162 | ipsum.py 163 | dolor.py 164 | second_module/ 165 | __init__.py 166 | lorem.py 167 | sit.py 168 | amet.py 169 | venv/ 170 | mkdocs.yml 171 | README.md 172 | ``` 173 | 174 | A full `mkdocs.yml` for the project might look like this: 175 | 176 | ```yaml mkdocs.yml 177 | site_name: Project 178 | 179 | nav: 180 | - Home: index.md 181 | - API Reference: autoapi/ 182 | 183 | plugins: 184 | - mkdocs-autoapi 185 | - mkdocstrings 186 | 187 | theme: 188 | name: readthedocs 189 | ``` 190 | 191 | More information MkDocs configuration through `mkdocs.yml` can be found in the 192 | [MkDocs User Guide](https://www.mkdocs.org/user-guide/configuration/). 193 | 194 | ## Contributing 195 | 196 | Contributions are always welcome! Please submit a pull request or open an issue 197 | to get started. 198 | 199 | ## License 200 | 201 | This project is licensed under the MIT License. See the [LICENSE](https://github.com/jcayers20/mkdocs-autoapi/blob/main/LICENSE) file 202 | for more information. 203 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We greatly appreciate contributions to the project! Please read the guidelines 4 | below to get started. 5 | 6 | ## Issues and Discussions 7 | 8 | If you've found a bug, have a question, or have an idea for a new feature, 9 | please first check the [existing issues](https://github.com/jcayers20/mkdocs-autoapi/issues) 10 | to see if your issue has already been reported. If it hasn't, you can create a 11 | new issue by clicking the "New Issue" button on the issues page. The sections 12 | below provide guidance on what to include in your issue. 13 | 14 | ### Bug Reports 15 | 16 | When creating an issue for a bug, please include the following information: 17 | * A clear and descriptive title starting with "Bug: " 18 | * A detailed description of the bug 19 | * Steps to reproduce the bug (preferably with a minimal working example) 20 | 21 | ### Feature Requests 22 | 23 | When creating an issue for a feature request, please include the following 24 | information: 25 | * A clear and descriptive title starting with "Feature: " 26 | * A detailed description of the feature 27 | 28 | ### Questions 29 | 30 | If you have a question, please feel free to ask it in the [discussions](https://github.com/jcayers20/mkdocs-autoapi/discussions). 31 | We'll do our best to answer as soon as possible. If your question ends up being 32 | a bug or feature request, we'll work with you to create an issue for it. 33 | 34 | ## Setting Up a Local Development Environment 35 | 36 | ### Getting a Local Copy of the Project 37 | 38 | To get started, you'll need to fork the repository and clone it to your machine. 39 | GitHub has [a great guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo?tool=webui) 40 | on how to do this. 41 | 42 | ### Creating a Virtual Environment 43 | 44 | We recommend using a virtual environment to keep your environment isolated for 45 | your work on this project. You can do this easily through the terminal. Note 46 | that the project is intended to work with Python 3.6 or later, so we recommend 47 | that you configure your interpreter to use Python 3.6. 48 | 49 | On Linux: 50 | ```bash 51 | $ python3 -m venv path/to/virtual_environment_directory # create 52 | $ source path/to/virtual_environment_directory/bin/activate # activate 53 | ``` 54 | 55 | On Windows: 56 | ```shell 57 | > python -m venv path\to\virtual_environment_directory # create 58 | > path\to\virtual_environment_directory\Scripts\activate # activate 59 | ``` 60 | 61 | Python IDE's generally have built-in support for virtual environments, so you 62 | can also set up a virtual environment through your IDE. Here are instructions 63 | for setting up a virtual environment in some popular IDE's: 64 | * [PyCharm](https://www.jetbrains.com/help/pycharm/creating-virtual-environment.html) 65 | * [VS Code](https://code.visualstudio.com/docs/python/environments) 66 | 67 | ### Installing Requirements 68 | 69 | Requirements are listed in `requirements.txt`. You can install them with pip: 70 | 71 | ```bash 72 | pip install -r requirements.txt 73 | ``` 74 | 75 | ### Enabling Pre-Commit Hooks 76 | 77 | We use pre-commit hooks to ensure that code is formatted consistently. Please be 78 | sure to enable these hooks before making changes. 79 | 80 | ```bash 81 | pre-commit install 82 | ``` 83 | 84 | Once enabled, the pre-commit hooks will run automatically when you attempt to 85 | commit changes. The first time you run the hooks, they may take a while to 86 | complete as the environment is set up. 87 | 88 | Note: Having `ruff` run on save will help you catch formatting problems before 89 | they are caught by the pre-commit hooks. You can enable this in your IDE. 90 | 91 | * [PyCharm](https://plugins.jetbrains.com/plugin/20574-ruff) 92 | * [VS Code](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff) 93 | 94 | ### Creating a Working Branch 95 | 96 | Before you start making changes, you'll need to create a new branch to work in. 97 | This helps keep your changes isolated from the main branch and makes it easier 98 | to submit a pull request when you're done. 99 | 100 | ```bash 101 | git checkout -b your-branch-name [source-branch-name] 102 | ``` 103 | 104 | Note that `source-branch-name` is the branch you're branching from. For now, 105 | this will generally be `develop`. In the future, we will have versioned branches 106 | for the next bugfix release, the next minor release, and the next major release. 107 | Once we get there, you'll want to branch from the appropriate version branch. 108 | * Bug fixes: Next bugfix release branch 109 | * Features that are backwards-compatible: Next minor release branch 110 | * Features that are not backwards-compatible: Next major release branch 111 | 112 | ## Making Changes 113 | 114 | Once you've set up your local development environment, you're ready to start 115 | making changes. A couple of things to keep in mind as you make changes: 116 | 117 | 1. **Assign Yourself**: If you're working on an issue, please assign yourself to 118 | the issue so that others know you're working on it. 119 | 2. **Reference Issue(s)**: If your changes are related to an issue, be sure to 120 | reference that issue in your commit messages. This helps us track changes 121 | back to the issues they address. 122 | 123 | ## Submitting a Pull Request 124 | 125 | When you're ready to submit your changes, you'll need to create a [pull request](https://docs.github.com/en/pull-requests). 126 | to merge your changes into the appropriate branch. We'll review your changes, 127 | provide feedback, and work with you to get your changes merged. Here are a few 128 | things to keep in mind when submitting a pull request: 129 | 130 | 1. **Use a Descriptive Title**: Your pull request title should be descriptive 131 | and concise. It should give us a good idea of what your changes are about. 132 | 2. **Describe Your Changes**: Be sure to provide a clear description of the 133 | changes you've made and why you've made them. This helps us understand your 134 | changes. 135 | 3. **Reference Issues**: If your changes are related to an issue, be sure to 136 | reference that issue in your pull request. If your work completes an issue, then 137 | use closing keywords (e.g., `closes`, `fixes`, `resolves`) so that the issue 138 | will be automatically closed when the pull request is merged. 139 | -------------------------------------------------------------------------------- /mkdocs_autoapi/section_index/rewrite.py: -------------------------------------------------------------------------------- 1 | """Logic for rewriting the navigation data.""" 2 | 3 | # built-in imports 4 | import pathlib 5 | import textwrap 6 | from typing import Callable, Optional, Tuple, Union 7 | 8 | from jinja2 import BaseLoader, Environment 9 | 10 | __all__ = ["TemplateRewritingLoader"] 11 | 12 | 13 | class TemplateRewritingLoader(BaseLoader): 14 | """A Jinja2 template loader that rewrites certain templates.""" 15 | 16 | def __init__(self, loader: BaseLoader): 17 | """Initialize a TemplateRewritingLoader instance.""" 18 | self.loader = loader 19 | self.found_supported_theme = False 20 | 21 | def get_source( 22 | self, 23 | environment: Environment, 24 | template: str, 25 | ) -> Tuple[str, str, Union[Callable[[], bool], None]]: 26 | """Get the source of a template.""" 27 | src, filename, uptodate = self.loader.get_source(environment, template) 28 | old_src = src 29 | assert filename is not None 30 | path = pathlib.Path(filename).as_posix() 31 | 32 | if path.endswith("/mkdocs/templates/sitemap.xml"): 33 | src = _transform_mkdocs_sitemap_template(src) 34 | else: 35 | # the second path is used in MkDocs-Material >= 9.4 36 | if path.endswith( 37 | ( 38 | "/material/partials/nav-item.html", 39 | "/material/templates/partials/nav-item.html", 40 | ), 41 | ): 42 | src = _transform_material_nav_item_template(src) 43 | elif path.endswith( 44 | ( 45 | "/material/partials/tabs-item.html", 46 | "/material/templates/partials/tabs-item.html", 47 | ), 48 | ): 49 | src = _transform_material_tabs_item_template(src) 50 | elif path.endswith("/themes/readthedocs/base.html"): 51 | src = _transform_readthedocs_base_template(src) 52 | elif path.endswith("/nature/base.html"): 53 | src = None # Just works! 54 | else: 55 | return src, filename, uptodate 56 | self.found_supported_theme = True 57 | 58 | return src or old_src, filename, uptodate 59 | 60 | 61 | def _transform_mkdocs_sitemap_template(src: str) -> Union[str, None]: 62 | """Transform the sitemap template.""" 63 | if " in pages " in src: 64 | return None 65 | # The below only for versions <= 1.1.2. 66 | return src.replace( 67 | "{%- else %}", 68 | "{%- endif %}{% if item.url %}", 69 | ) 70 | 71 | 72 | def _transform_material_nav_item_template(src: str) -> str: 73 | """Transform the Material for MkDocs nav-item template.""" 74 | if "navigation.indexes" in src: 75 | return src.replace( 76 | "{% set indexes = [] %}", 77 | "{% set indexes = [nav_item] if nav_item.url else [] %}", 78 | ).replace( 79 | "{% if nav_item.children | length > 1 %}", 80 | "{% if nav_item.children %}", 81 | ) 82 | 83 | # The above only for versions >= 7.3, the below only for versions < 7.3. 84 | src = src.replace( 85 | "{% if nav_item.children %}", 86 | "{% if nav_item.children and not ('navigation.tabs' in features and level == 1 and not nav_item.active and nav_item.url) %}", # noqa: E501 87 | ) 88 | 89 | repl = """\ 90 | {% if nav_item.url %} 91 | 93 | {% endif %} 94 | [...] 95 | {% if nav_item.url %}{% endif %} 96 | """ # noqa: E501 97 | lines = src.split("\n") 98 | for i, (line1, line2) in enumerate(zip(lines, lines[1:])): 99 | for a, b in (line1, line2), (line2, line1): 100 | if "md-nav__icon" in a and b.endswith("{{ nav_item.title }}"): 101 | lines[i : i + 2] = (a, _replace_line(b, repl)) 102 | break 103 | return "\n".join(lines) 104 | 105 | 106 | def _transform_material_tabs_item_template(src: str) -> str: 107 | """Transform the Material for MkDocs tabs-item template.""" 108 | src = src.replace( 109 | "{% if first.children %}", "{% if first.children and not first.url %}" 110 | ) 111 | # The above only for versions >= 9.2 112 | src = src.replace( 113 | "{% if nav_item.children %}", 114 | "{% if nav_item.children and not nav_item.url %}", 115 | ) 116 | # The above only for versions > 6.1.7, the below only for versions <= 6.1.7. 117 | return src.replace( 118 | "(nav_item.children | first).url", 119 | "(nav_item.url or (nav_item.children | first).url)", 120 | ).replace( 121 | "if (nav_item.children | first).children", 122 | "if (nav_item.children | first).children and not nav_item.url", 123 | ) 124 | 125 | 126 | def _transform_readthedocs_base_template(src: str) -> Union[str, None]: 127 | """Transform the ReadTheDocs base template.""" 128 | if " if nav_item.is_page " in src: 129 | return None 130 | # The below only for versions < 1.6: 131 | repl = """\ 132 | {% if nav_item.url %} 133 | 140 | {% endif %} 141 | """ # noqa: E501 142 | lines = src.split("\n") 143 | for i, line in enumerate(lines): 144 | if "{{ nav_item.title }}" in line: 145 | lines[i] = _replace_line(lines[i], repl) 146 | return "\n".join(lines) 147 | 148 | 149 | def _replace_line( 150 | line: str, 151 | wrapper: str, 152 | new_line: Optional[str] = None, 153 | ) -> str: 154 | """Replace a line with a wrapper.""" 155 | leading_space = line[: -len(line.lstrip())] 156 | if new_line is None: 157 | new_line = line.lstrip() 158 | new_text = textwrap.dedent(wrapper.rstrip()).replace("[...]", new_line) 159 | return textwrap.indent(new_text, leading_space) 160 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Setting the Project Root 4 | 5 | By default, the plugin considers the directory containing `mkdocs.yml` the 6 | project root directory. To use a different directory, specify the directory 7 | path in the `autoapi_dir` configuration option. The path can be absolute or 8 | relative to the directory containing `mkdocs.yml`. 9 | 10 | ```yaml 11 | plugins: 12 | - ... other plugin configuration ... 13 | - mkdocs-autoapi: 14 | autoapi_dir: path/to/autoapi/dir 15 | - mkdocstrings 16 | ``` 17 | 18 | A common use case for this option is projects using the 19 | [src layout](https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/). 20 | 21 | !!! note "Notes" 22 | 23 | If a directory containing `__init__.py` is specified, then the directory 24 | will be included in the relative path of its children. If not, then the 25 | directory will not be included. 26 | 27 | Be sure to include the `autoapi_dir` directory in the `paths` configuration 28 | for the `mkdocstrings` handler to ensure that the documentation is generated 29 | relative to the correct directory. If `autoapi_dir` does not contain 30 | `__init__.py`, then `autoapi_dir` must be included. If it does, then the 31 | parent directory of `autoapi_dir` must be included. For more information, 32 | see the `mkdocstrings` [documentation](https://mkdocstrings.github.io/python/usage/#using-the-paths-option). 33 | 34 | !!! example 35 | 36 | Consider a project with the following structure: 37 | 38 | ```tree 39 | project/ 40 | .venv/ 41 | ... 42 | docs/ 43 | index.md 44 | src/ 45 | awesome_package/ 46 | __init__.py 47 | module.py 48 | tools/ 49 | generate_awesomeness.py 50 | decrease_world_suck.py 51 | mkdocs.yml 52 | noxfile.py 53 | pyproject.toml 54 | README.md 55 | setup.py 56 | ``` 57 | 58 | For this project, it may or may not be desirable to include the `tools` 59 | directory in the API reference and we probably don't want to include 60 | the `*.py` files in the top-level directory. To ignore these items, we can 61 | set `autoapi_dir` to `src`: 62 | 63 | ```yaml title="mkdocs.yml" 64 | plugins: 65 | - ... other plugin configuration ... 66 | - mkdocs-autoapi: 67 | autoapi_dir: src # or /path/to/project/src 68 | - mkdocstrings: 69 | handlers: 70 | python: 71 | paths: 72 | - src 73 | ``` 74 | 75 | ## Including and Ignoring Patterns 76 | 77 | The `autoapi_ignore` configuration option allows for exclusion of files matching 78 | the specified pattern(s). This option accepts a list of [glob](https://man7.org/linux/man-pages/man7/glob.7.html) 79 | patterns. These patterns are evaluated relative to 80 | [autoapi_dir](#setting-the-project-root). 81 | 82 | Likewise, the `autoapi_file_patterns` configuration option allows for control of 83 | which files are included in the API reference. This option also accepts a list 84 | of glob patterns which are evaluated (recursively) relative to `autoapi_dir`. By 85 | default, all files with `.py` and `.pyi` extensions are included. 86 | 87 | !!! note 88 | The following patterns are commonly used for virtual environments and are 89 | always ignored: 90 | 91 | `venv/**/*`
92 | `.venv/**/*` 93 | 94 | !!! example 95 | 96 | Consider a project with the following structure: 97 | 98 | ```tree 99 | project/ 100 | docs/ 101 | index.md 102 | module/ 103 | __init__.py 104 | lorem.py 105 | ipsum.py 106 | dolor.py 107 | second_module/ 108 | __init__.py 109 | lorem.py 110 | sit.py 111 | amet.py 112 | mkdocs.yml 113 | README.md 114 | ``` 115 | 116 | Suppose we want to ignore all files named `lorem.py` and all files in the 117 | `second_module` directory. We can add the following configuration to 118 | `mkdocs.yml`: 119 | 120 | ```yaml title="mkdocs.yml" 121 | plugins: 122 | - ... other plugin configuration ... 123 | - mkdocs-autoapi: 124 | autoapi_ignore: 125 | - **/lorem.py 126 | - second_module/**/*.py 127 | autoapi_file_patterns: 128 | - *.py # ignoring .pyi to improve performance since no stubs present 129 | - mkdocstrings 130 | ``` 131 | 132 | ## Controlling Output 133 | 134 | The plugin supports two configuration options for 135 | controlling output: 136 | 137 | 1. `autoapi_keep_files` (`bool`): If `True`, then the plugin will generate local 138 | copies of the Markdown files in `/`. If `False`, 139 | Markdown files will only be created in temp directory. Default is `False`. 140 | 2. `autoapi_root` (`str`): The directory in which to save the generated Markdown 141 | files. For local output, this directory is relative to `docs_dir`. Default 142 | is `autoapi`. 143 | 144 | !!! example 145 | 146 | Consider a project with the following structure: 147 | 148 | ```tree 149 | project/ 150 | docs/ 151 | index.md 152 | module/ 153 | __init__.py 154 | lorem.py 155 | ipsum.py 156 | dolor.py 157 | second_module/ 158 | __init__.py 159 | lorem.py 160 | sit.py 161 | amet.py 162 | mkdocs.yml 163 | README.md 164 | ``` 165 | 166 | To generate local copies of the Markdown files in a directory named `api`, 167 | add the following configuration to `mkdocs.yml`: 168 | 169 | ```yaml title="mkdocs.yml" 170 | plugins: 171 | - ... other plugin configuration ... 172 | - mkdocs-autoapi: 173 | autoapi_keep_files: True 174 | autoapi_root: api 175 | - mkdocstrings 176 | ``` 177 | 178 | ## Disabling API Documentation Generation 179 | 180 | To disable API documentation generation, set the `autoapi_generate_api_docs` 181 | configuration option to `False`. This is useful when transitioning to manual 182 | documentation or when the API documentation is not needed. 183 | 184 | ## Including API Documentation in Navigation 185 | 186 | The inclusion of API documentation in the navigation can be controlled via the 187 | configuration option `autoapi_add_nav_entry`. This option accepts either a 188 | boolean value or a string. Behavior is as follows: 189 | 190 | * If `True`, then a section named "API Reference" will be added to the end of 191 | the navigation. 192 | * If `False`, then no section for the API documentation will be added. In this 193 | case, a manual link to the API documentation can be added to the navigation. 194 | * If a string, then a section with the specified name will be added to the end 195 | of the navigation. 196 | 197 | !!! example 198 | 199 | To include the API documentation in the navigation under the section 200 | "Reference", add the following configuration to `mkdocs.yml`: 201 | 202 | ```yaml title="mkdocs.yml" 203 | plugins: 204 | - ... other plugin configuration ... 205 | - mkdocs-autoapi: 206 | autoapi_add_nav_entry: Reference 207 | - mkdocstrings 208 | ``` 209 | 210 | !!! example 211 | 212 | To disable automatic addition of the API documentation to the navigation and 213 | instead add a manual link, add the following configuration to `mkdocs.yml`: 214 | 215 | ```yaml title="mkdocs.yml" 216 | nav: 217 | - Home: index.md 218 | - API: autoapi/ # target should be `autoapi_root` 219 | - Examples: examples.md 220 | 221 | plugins: 222 | - ... other plugin configuration ... 223 | - mkdocs-autoapi: 224 | autoapi_add_nav_entry: False 225 | - mkdocstrings 226 | ``` 227 | 228 | 229 | ## Putting It All Together 230 | 231 | !!! example 232 | 233 | Consider a project with the following structure: 234 | 235 | ```tree 236 | project/ 237 | docs/ 238 | index.md 239 | module/ 240 | __init__.py 241 | lorem.py 242 | ipsum.py 243 | dolor.py 244 | second_module/ 245 | __init__.py 246 | lorem.py 247 | sit.py 248 | amet.py 249 | mkdocs.yml 250 | README.md 251 | ``` 252 | 253 | A minimal `mkdocs.yml` file for this project might look like this: 254 | 255 | ```yaml title="mkdocs.yml" 256 | site_name: Project 257 | 258 | nav: 259 | - Home: index.md 260 | 261 | plugins: 262 | - search 263 | - mkdocs-autoapi 264 | - mkdocstrings 265 | ``` 266 | -------------------------------------------------------------------------------- /mkdocs_autoapi/autoapi.py: -------------------------------------------------------------------------------- 1 | """Logic used to implement AutoAPI functionality. 2 | 3 | As I work through the build, I'll update the documentation for this module. 4 | """ 5 | 6 | # built-in imports 7 | import os 8 | from collections import OrderedDict 9 | from pathlib import Path 10 | from typing import Iterable, List, Optional, Set 11 | 12 | # third-party imports 13 | from mkdocs.config.defaults import MkDocsConfig 14 | from mkdocs.exceptions import ConfigurationError 15 | 16 | # local imports 17 | import mkdocs_autoapi 18 | from mkdocs_autoapi.generate_files import nav 19 | from mkdocs_autoapi.logging import get_logger 20 | 21 | logger = get_logger("mkdocs-autoapi") 22 | 23 | 24 | def identify_files_to_document( 25 | path: Path, 26 | autoapi_file_patterns: List[str], 27 | autoapi_ignore: Optional[Iterable[str]] = None, 28 | ) -> Set[Path]: 29 | """Get a set of all Python files for which documentation must be generated. 30 | 31 | This function finds all Python files located in `path`, then removes any 32 | that match at least one member of `autoapi_ignore`. 33 | 34 | Steps: 35 | 1. Get set of all files matching `autoapi_file_patterns` in `path`. 36 | 2. For each pattern in `autoapi_ignore`, get set of all files matching 37 | the pattern and reduce the set of Python files to only those files 38 | that *do not* match the pattern. 39 | 3. Return the final set of files to include. 40 | 41 | Args: 42 | path: 43 | The path to search. 44 | autoapi_file_patterns: 45 | The patterns to search for. 46 | autoapi_ignore: 47 | The patterns to autoapi_ignore. 48 | 49 | Returns (Set[pathlib.Path]): 50 | The set of all Python files in `path` that *do not* match any member of 51 | `autoapi_ignore`. 52 | """ 53 | # Step 1 54 | reversed_autoapi_file_patterns = autoapi_file_patterns.copy() 55 | reversed_autoapi_file_patterns.reverse() 56 | 57 | # Step 1 58 | files_to_document = OrderedDict() 59 | for pattern in reversed_autoapi_file_patterns: 60 | pattern_matches = set(path.rglob(pattern=pattern)) 61 | for file in pattern_matches: 62 | file_key = file.with_suffix(suffix="") 63 | files_to_document.update({file_key: file}) 64 | files_to_document = set(files_to_document.values()) 65 | 66 | # Step 2 67 | if autoapi_ignore: 68 | for pattern in autoapi_ignore: 69 | autoapi_ignored_files = set(path.glob(pattern=pattern)) 70 | files_to_document = files_to_document.difference( 71 | autoapi_ignored_files 72 | ) 73 | 74 | # Step 3 75 | return {p.resolve() for p in files_to_document} 76 | 77 | 78 | def add_autoapi_nav_entry( 79 | config: MkDocsConfig, 80 | ) -> None: 81 | """Add the AutoAPI section to the navigation. 82 | 83 | Steps: 84 | 1. Create the `autoapi_root_ref` variable. 85 | 2. Create the `autoapi_section_title` variable. 86 | 3. Append the AutoAPI section to the navigation. 87 | 88 | Args: 89 | config: 90 | The MkDocs configuration object. 91 | 92 | Returns: 93 | None; the navigation is updated in place. 94 | """ 95 | # Step 1 96 | if not config["autoapi_root"].endswith("/"): 97 | autoapi_root_ref = f"{config['autoapi_root']}/" 98 | else: 99 | autoapi_root_ref = config["autoapi_root"] 100 | 101 | # Step 2 102 | if config["autoapi_add_nav_entry"] is True: 103 | autoapi_section_title = "API Reference" 104 | else: 105 | autoapi_section_title = config["autoapi_add_nav_entry"] 106 | 107 | # Step 3 108 | if not config.nav: 109 | config.nav = [] 110 | config.nav.append({autoapi_section_title: autoapi_root_ref}) 111 | 112 | 113 | def create_docs( 114 | config: MkDocsConfig, 115 | ) -> None: 116 | r"""Use AutoAPI approach to create documentation for a project. 117 | 118 | Steps: 119 | 1. Define variables. 120 | 2. Add the AutoAPI section to the navigation if desired. 121 | 3. Create a new `Nav` object. 122 | 4. Get the set of all Python files to document. 123 | 5. If `autoapi_dir` is a package, adjust `autoapi_dir` to its parent. 124 | 6. For each file found: 125 | 1. Get the module path and document path. 126 | 2. Get the module path parts. 127 | 3. Remove the last part of the module path parts if it is 128 | "\_\_init\_\_". 129 | 4. Create a new entry in the `Nav` object. 130 | 5. Create the module identifier. 131 | 6. Create the documentation file. 132 | 7. Set the edit path. 133 | 7. Write the navigation to `autoapi/summary.md`. 134 | 135 | Args: 136 | config: 137 | The MkDocs configuration object. 138 | 139 | Returns: 140 | None. 141 | """ 142 | # Step 1 143 | logger.debug(msg="Generating AutoAPI documentation ...") 144 | theme = config.theme.name 145 | handler = config.plugins["mkdocstrings"].config.default_handler 146 | autoapi_dir = Path(config["autoapi_dir"]) 147 | autoapi_ignore = config["autoapi_ignore"] 148 | autoapi_file_patterns = config["autoapi_file_patterns"] 149 | autoapi_add_nav_entry = config["autoapi_add_nav_entry"] 150 | docs_dir = Path(config["docs_dir"]) 151 | autoapi_root = config["autoapi_root"] 152 | autoapi_keep_files = config["autoapi_keep_files"] 153 | local_summary_path = docs_dir / autoapi_root / "summary.md" 154 | temp_summary_path = f"{autoapi_root}/summary.md" 155 | 156 | # Step 2 157 | if autoapi_add_nav_entry: 158 | add_autoapi_nav_entry(config=config) 159 | logger.debug(msg="... Added AutoAPI section to navigation ...") 160 | else: 161 | logger.debug(msg="... Skipped adding AutoAPI section to navigation ...") 162 | if autoapi_keep_files: 163 | local_path = docs_dir / autoapi_root 164 | logger.debug( 165 | msg=f"... AutoAPI files will be saved locally in {local_path} ..." 166 | ) 167 | else: 168 | logger.debug(msg="... AutoAPI files will not be saved locally ...") 169 | 170 | # Step 3 171 | navigation = nav.Nav() 172 | 173 | # Step 4 174 | files_to_document = identify_files_to_document( 175 | path=autoapi_dir, 176 | autoapi_file_patterns=autoapi_file_patterns, 177 | autoapi_ignore=autoapi_ignore, 178 | ) 179 | logger.debug( 180 | msg=f"... Found {len(files_to_document)} files to document ..." 181 | ) 182 | 183 | # Step 5 184 | if (autoapi_dir / "__init__.py").exists(): 185 | autoapi_dir = autoapi_dir.parent 186 | logger.debug(msg="... Adjusted AutoAPI directory to parent package ...") 187 | 188 | # Step 6 189 | for file in sorted(files_to_document): 190 | # Step 6.1 191 | try: 192 | module_path = file.relative_to( 193 | autoapi_dir.resolve() 194 | ).parent.with_suffix("") 195 | except ValueError: 196 | module_path = Path("") 197 | doc_path = file.relative_to(file.parent).with_suffix(".md") 198 | full_temp_doc_path = autoapi_root / module_path / doc_path 199 | full_local_doc_path = docs_dir / full_temp_doc_path 200 | 201 | # Step 6.2 202 | module_path_parts = list(module_path.parts) 203 | module_path_parts.append(doc_path.stem) 204 | module_path_parts = tuple(module_path_parts) 205 | 206 | # Step 6.3 207 | if module_path_parts[-1] == "__init__": 208 | if len(module_path_parts) == 1: 209 | continue 210 | module_path_parts = module_path_parts[:-1] 211 | doc_path = doc_path.with_name("index.md") 212 | full_local_doc_path = full_local_doc_path.with_name("index.md") 213 | full_temp_doc_path = full_temp_doc_path.with_name("index.md") 214 | 215 | if theme == "mkdocs": 216 | nav_tuple = list(module_path_parts) 217 | nav_tuple.append("Index") 218 | nav_tuple = tuple(nav_tuple) 219 | else: 220 | nav_tuple = module_path_parts 221 | else: 222 | nav_tuple = module_path_parts 223 | 224 | # Step 6.4 225 | navigation[nav_tuple] = (module_path / doc_path).as_posix() 226 | 227 | # Step 6.5 228 | if handler == "python": 229 | module_identifier = ".".join(module_path_parts) 230 | elif handler == "vba": 231 | module_identifier = file.relative_to(autoapi_dir.resolve()) 232 | else: 233 | raise ConfigurationError( 234 | f"Mkdocstrings handler '{handler}' is not supported." 235 | ) 236 | 237 | # Step 6.6 238 | if autoapi_keep_files: 239 | if not full_local_doc_path.parents[0].exists(): 240 | os.makedirs(full_local_doc_path.parents[0]) 241 | 242 | try: 243 | with open(full_local_doc_path, "r+") as doc: 244 | old_content = doc.read() 245 | new_content = f"::: {module_identifier}\n" 246 | 247 | if old_content != new_content: 248 | doc.seek(0) 249 | doc.write(new_content) 250 | doc.truncate() 251 | 252 | except FileNotFoundError: 253 | with open(full_local_doc_path, "w") as doc: 254 | print(f"::: {module_identifier}", file=doc) 255 | 256 | with mkdocs_autoapi.generate_files.open(full_temp_doc_path, "w") as doc: 257 | print(f"::: {module_identifier}", file=doc) 258 | 259 | # Step 6.7 260 | mkdocs_autoapi.generate_files.set_edit_path(full_temp_doc_path, file) 261 | 262 | # Step 7 263 | if autoapi_keep_files: 264 | try: 265 | with open(local_summary_path, "r+") as local_nav_file: 266 | old_content = local_nav_file.read() 267 | literate_nav = list(navigation.build_literate_nav()) 268 | new_content = "".join(literate_nav) 269 | 270 | if old_content != new_content: 271 | local_nav_file.seek(0) 272 | local_nav_file.write(new_content) 273 | local_nav_file.truncate() 274 | 275 | except FileNotFoundError: 276 | with open(local_summary_path, "w") as local_nav_file: 277 | local_nav_file.writelines(navigation.build_literate_nav()) 278 | 279 | logger.debug( 280 | msg=f"... Saved AutoAPI summary file locally in {local_summary_path} ..." 281 | ) 282 | 283 | with mkdocs_autoapi.generate_files.open( 284 | temp_summary_path, "w" 285 | ) as temp_nav_file: 286 | temp_nav_file.writelines(navigation.build_literate_nav()) 287 | logger.debug("... Finished generating AutoAPI documentation.") 288 | -------------------------------------------------------------------------------- /mkdocs_autoapi/plugin.py: -------------------------------------------------------------------------------- 1 | """Plugin definition for mkdocs-autoapi.""" 2 | 3 | # built-in imports 4 | import collections 5 | import os 6 | import tempfile 7 | import urllib.parse 8 | from pathlib import Path 9 | from typing import Optional 10 | 11 | # third-party imports 12 | from jinja2 import Environment 13 | from mkdocs.config import Config, config_options 14 | from mkdocs.config.defaults import MkDocsConfig 15 | from mkdocs.exceptions import ConfigurationError, PluginError 16 | from mkdocs.plugins import BasePlugin 17 | from mkdocs.structure.files import Files 18 | from mkdocs.structure.nav import Navigation, Section 19 | from mkdocs.structure.pages import Page 20 | 21 | # local imports 22 | from mkdocs_autoapi.autoapi import add_autoapi_nav_entry, create_docs 23 | from mkdocs_autoapi.generate_files.editor import FilesEditor 24 | from mkdocs_autoapi.literate_nav import resolve 25 | from mkdocs_autoapi.logging import get_logger 26 | from mkdocs_autoapi.section_index import rewrite 27 | from mkdocs_autoapi.section_index.section_page import SectionPage 28 | 29 | logger = get_logger(name="mkdocs-autoapi") 30 | 31 | 32 | class AutoApiPluginConfig(Config): 33 | """Configuration options for plugin.""" 34 | 35 | autoapi_dir = config_options.Dir(exists=True, default=".") 36 | autoapi_file_patterns = config_options.ListOfItems( 37 | config_options.Type(str), 38 | default=["*.py", "*.pyi"], 39 | ) 40 | autoapi_ignore = config_options.ListOfItems( 41 | config_options.Type(str), default=[] 42 | ) 43 | autoapi_keep_files = config_options.Type(bool, default=False) 44 | autoapi_generate_api_docs = config_options.Type(bool, default=True) 45 | autoapi_add_nav_entry = config_options.Type((str, bool), default=True) 46 | autoapi_root = config_options.Type(str, default="autoapi") 47 | 48 | 49 | class AutoApiPlugin(BasePlugin[AutoApiPluginConfig]): 50 | """Plugin logic definition.""" 51 | 52 | def on_config(self, config: MkDocsConfig) -> Optional[Config]: 53 | """Validate the plugin configuration. 54 | 55 | # Step 1 56 | 1. Check if `mkdocstrings` is included in plugin configuration. 57 | 2a. If `mkdocstrings` is included, then validate its configuration. 58 | 1. Get the `mkdocstrings` configuration object. 59 | 2. If `mkdocstrings` is not enabled, then warn the user. 60 | 3. Get the `handlers` configuration. 61 | 4. Identify the AutoAPI directory. If the value provided by the 62 | user is a Python package, then get the parent directory. 63 | Otherwise, use the provided value. 64 | 5. Check if the AutoAPI directory is included in the paths for 65 | each `mkdocstrings` handler. If not, then warn the user. 66 | 2b. If `mkdocstrings` is not included, then warn the user. 67 | 3. Return. 68 | 69 | 70 | Args: 71 | config: 72 | The MkDocs configuration object. 73 | 74 | Returns: 75 | The validated plugin configuration. 76 | """ 77 | # Step 1 78 | logger.debug(msg="Validating plugin configuration ...") 79 | is_mkdocstrings_included = "mkdocstrings" in list(config.plugins.keys()) 80 | 81 | # Step 2a 82 | if is_mkdocstrings_included: 83 | # Step 2a.1 84 | mkdocstrings_configuration = config.plugins["mkdocstrings"].config 85 | 86 | # Step 2a.2 87 | if not mkdocstrings_configuration.enabled: 88 | logger.warning( 89 | msg="mkdocstrings is not enabled.\n HINT: Set `enabled: True` in mkdocstrings configuration." 90 | ) 91 | 92 | # Step 2a.3 93 | mkdocstrings_handlers_configuration = ( 94 | mkdocstrings_configuration.handlers 95 | ) 96 | if mkdocstrings_handlers_configuration == dict(): 97 | mkdocstrings_handlers_configuration = { 98 | mkdocstrings_configuration.default_handler: {"paths": ["."]} 99 | } 100 | 101 | # Step 2a.4 102 | autoapi_dir = Path(self.config.autoapi_dir).absolute() 103 | if "__init__.py" in os.listdir(autoapi_dir): 104 | autoapi_dir = autoapi_dir.parent.absolute() 105 | 106 | # Step 2a.5 107 | mkdocs_yml_dir = Path(config.config_file_path).parent.absolute() 108 | for handler in mkdocstrings_handlers_configuration.keys(): 109 | paths = [ 110 | Path( 111 | os.path.abspath(os.path.join(mkdocs_yml_dir, p)) 112 | ).absolute() 113 | for p in mkdocstrings_handlers_configuration[handler][ 114 | "paths" 115 | ] 116 | ] 117 | if autoapi_dir not in paths: 118 | relative_autoapi_dir = os.path.relpath( 119 | path=autoapi_dir, 120 | start=mkdocs_yml_dir, 121 | ).replace("\\", "/") 122 | logger.warning( 123 | msg=f'AutoAPI directory not found in paths for `mkdocstrings` handler "{handler}".\n HINT: Add "{relative_autoapi_dir}" to the `paths` list in the `mkdocstrings` handler configuration.' 124 | ) 125 | 126 | # Step 2a.6 127 | default_handler = mkdocstrings_configuration.default_handler 128 | if default_handler not in ["python", "vba"]: 129 | raise ConfigurationError( 130 | f"mkdocstrings default handler must be one of ['python', 'python-legacy', 'vba'], not: {handler}" 131 | ) 132 | 133 | # Step 2b 134 | else: 135 | logger.warning( 136 | msg="mkdocstrings is not included in mkdocs configuration.\n HINT: Add `mkdocstrings` to the `plugins` list in mkdocs configuration file." 137 | ) 138 | 139 | # Step 3 140 | return config 141 | 142 | def on_files(self, files: Files, config: MkDocsConfig) -> Files: 143 | """Generate autoAPI documentation files. 144 | 145 | Steps: 146 | 1. Create a temporary directory to store the generated files. 147 | 2. Ignore the virtual environment from the documentation if it is 148 | not already ignored. 149 | 3. Create the autoAPI documentation files. 150 | 4. Store the paths of the generated files. 151 | 5. Return the updated files object. 152 | 153 | Args: 154 | files: 155 | The MkDocs files object. 156 | config: 157 | The MkDocs configuration. 158 | 159 | Returns: 160 | The updated MkDocs files object. 161 | """ 162 | # Step 1 163 | self._dir = tempfile.TemporaryDirectory( 164 | prefix="autoapi", 165 | ) 166 | config.update(self.config) 167 | 168 | # Step 2 169 | if "venv/**/*" not in self.config.autoapi_ignore: 170 | self.config.autoapi_ignore.append("venv/**/*") 171 | if ".venv/**/*" not in self.config.autoapi_ignore: 172 | self.config.autoapi_ignore.append(".venv/**/*") 173 | 174 | # Step 4 175 | with FilesEditor( 176 | files=files, 177 | config=config, 178 | directory=self._dir.name, 179 | ) as editor: 180 | try: 181 | if self.config.autoapi_generate_api_docs: 182 | create_docs(config=config) 183 | elif self.config.autoapi_add_nav_entry: 184 | add_autoapi_nav_entry(config=config) 185 | logger.debug(msg="Added AutoAPI section to navigation.") 186 | except Exception as e: 187 | raise PluginError(str(e)) 188 | 189 | # Step 5 190 | self._edit_paths = dict(editor.edit_paths) 191 | 192 | # Step 6 193 | markdown_extensions = config.markdown_extensions 194 | markdown_config = { 195 | "markdown_extensions": markdown_extensions, 196 | "extension_configs": config["mdx_configs"], 197 | "tab_length": 4, 198 | } 199 | 200 | # Step 7 201 | config.nav = resolve.resolve_directories_in_nav( 202 | nav_data=config.nav, 203 | files=editor.files, 204 | nav_file_name="summary.md", 205 | implicit_index=False, 206 | markdown_config=markdown_config, 207 | ) 208 | self._files = editor.files 209 | 210 | # Step 8 211 | return editor.files 212 | 213 | def on_nav(self, nav: Navigation, config, files) -> Navigation: 214 | """Apply plugin-specific transformations to the navigation.""" 215 | todo = collections.deque((nav.items,)) 216 | while todo: 217 | items = todo.popleft() 218 | for i, section in enumerate(items): 219 | if not isinstance(section, Section) or not section.children: 220 | continue 221 | todo.append(section.children) 222 | page = section.children[0] 223 | if not isinstance(page, Page): 224 | continue 225 | assert not page.children 226 | if not page.title and page.url: 227 | page.__class__ = SectionPage 228 | assert isinstance(page, SectionPage) 229 | page.is_section = page.is_page = True 230 | page.title = section.title 231 | page.parent = section.parent 232 | section.children.pop(0) 233 | page.children = section.children 234 | for child in page.children: 235 | child.parent = page 236 | items[i] = page 237 | self._nav = nav 238 | return nav 239 | 240 | def on_env(self, env: Environment, config, files) -> Environment: 241 | """Apply plugin-specific transformations to the Jinja environment.""" 242 | assert env.loader is not None 243 | env.loader = self._loader = rewrite.TemplateRewritingLoader(env.loader) 244 | return env 245 | 246 | def on_page_context(self, context, page, config, nav): 247 | """Apply plugin-specific transformations to a page's context.""" 248 | if nav != self._nav: 249 | self._nav = nav 250 | 251 | def on_page_content( 252 | self, 253 | html: str, 254 | page: Page, 255 | config: MkDocsConfig, 256 | files: Files, 257 | ) -> str: 258 | """Apply plugin-specific transformations to a page's content.""" 259 | if self.config.autoapi_generate_api_docs: 260 | repo_url = config.repo_url 261 | edit_uri = config.edit_uri 262 | 263 | src_path = page.file.src_uri 264 | if src_path in self._edit_paths: 265 | path = self._edit_paths.pop(src_path) 266 | if repo_url and edit_uri: 267 | if not edit_uri.startswith( 268 | ("?", "#") 269 | ) and not repo_url.endswith("/"): 270 | repo_url += "/" 271 | 272 | page.edit_url = path and urllib.parse.urljoin( 273 | base=urllib.parse.urljoin(base=repo_url, url=edit_uri), 274 | url=path, 275 | ) 276 | 277 | return html 278 | -------------------------------------------------------------------------------- /mkdocs_autoapi/literate_nav/parser.py: -------------------------------------------------------------------------------- 1 | """MkDocs literate nav parser.""" 2 | 3 | # built-in imports 4 | import copy 5 | import functools 6 | import itertools 7 | import logging 8 | import posixpath 9 | import urllib.parse 10 | import xml.etree.ElementTree as etree 11 | from typing import ( 12 | Callable, 13 | Dict, 14 | Iterator, 15 | List, 16 | Optional, 17 | Set, 18 | Tuple, 19 | Union, 20 | cast, 21 | ) 22 | 23 | # third-party imports 24 | import markdown 25 | import markdown.extensions 26 | import markdown.postprocessors 27 | import markdown.preprocessors 28 | import markdown.treeprocessors 29 | import mkdocs.utils 30 | 31 | # local imports 32 | from mkdocs_autoapi.literate_nav import exceptions 33 | from mkdocs_autoapi.literate_nav.globber import MkDocsGlobber 34 | 35 | log = logging.getLogger(f"mkdocs.plugins.{__name__}") 36 | 37 | _unescape: Callable[[str], str] 38 | try: 39 | _unescape = markdown.treeprocessors.UnescapeTreeprocessor().unescape 40 | except AttributeError: 41 | _unescape = markdown.postprocessors.UnescapePostprocessor().run # type: ignore[attr-defined] 42 | 43 | 44 | class Wildcard: 45 | """Wildcard for literate nav.""" 46 | 47 | trim_slash = False 48 | 49 | def __init__(self, *path_parts: str, fallback: bool = True): 50 | """Initialize a Wildcard instance.""" 51 | norm = posixpath.normpath(posixpath.join(*path_parts).lstrip("/")) 52 | if path_parts[-1].endswith("/") and not self.trim_slash: 53 | norm += "/" 54 | self.value = norm 55 | self.fallback = path_parts[-1] if fallback else None 56 | 57 | def __repr__(self): 58 | """Create a string representation of a Wildcard instance.""" 59 | return f"{type(self).__name__}({self.value!r})" 60 | 61 | 62 | NavWithWildcardsItem = Union[ 63 | Wildcard, 64 | str, 65 | "NavWithWildcards", 66 | Dict[Optional[str], Union[Wildcard, str, "NavWithWildcards"]], 67 | ] 68 | NavWithWildcards = List[NavWithWildcardsItem] 69 | 70 | NavItem = Union[str, Dict[Optional[str], Union[str, "Nav"]]] 71 | Nav = List[NavItem] 72 | 73 | RootStack = Tuple[str, ...] 74 | 75 | 76 | class DirectoryWildcard(Wildcard): 77 | """Wildcard for directories in literate nav.""" 78 | 79 | trim_slash = True 80 | 81 | 82 | class NavParser: 83 | """Navigation parser for literate nav.""" 84 | 85 | def __init__( 86 | self, 87 | get_nav_for_dir: Callable[[str], Union[Tuple[str, str], None]], 88 | globber: MkDocsGlobber, 89 | implicit_index: bool = False, 90 | markdown_config: Optional[dict] = None, 91 | ): 92 | """Initialize a NavParser instance.""" 93 | self.get_nav_for_dir = get_nav_for_dir 94 | self.globber = globber 95 | self.implicit_index = implicit_index 96 | self._markdown_config = markdown_config or {} 97 | self.seen_items: Set[str] = set() 98 | self._warn = functools.lru_cache()(log.warning) 99 | 100 | def markdown_to_nav(self, roots: Tuple[str, ...] = (".",)) -> Nav: 101 | """Convert a Markdown file to a navigation structure.""" 102 | root = roots[0] 103 | 104 | if dir_nav := self.get_nav_for_dir(root): 105 | nav_file_name, markdown_content = dir_nav 106 | nav = _extract_nav_from_content( 107 | self._markdown_config, markdown_content 108 | ) 109 | 110 | if nav is not None: 111 | self_path = posixpath.normpath( 112 | posixpath.join(root, nav_file_name) 113 | ) 114 | if not ( 115 | self.implicit_index 116 | and self_path == self.globber.find_index(root) 117 | ): 118 | self.seen_items.add(self_path) 119 | 120 | first_item: Optional[Wildcard] = None 121 | if self.implicit_index: 122 | if found_index := self.globber.find_index(root): 123 | first_item = Wildcard( 124 | root, "/" + found_index, fallback=False 125 | ) 126 | return self._resolve_wildcards( 127 | self._list_element_to_nav(nav, root, first_item), roots 128 | ) 129 | 130 | log.debug(f"Navigation for {root!r} will be inferred.") 131 | return self._resolve_wildcards( 132 | [Wildcard(root, "*", fallback=False)], roots 133 | ) 134 | 135 | def _list_element_to_nav( 136 | self, 137 | section: etree.Element, 138 | root: str, 139 | first_item: Optional[Union[Wildcard, str]] = None, 140 | ) -> NavWithWildcards: 141 | """Convert a list element to a navigation structure.""" 142 | assert section.tag in _LIST_TAGS 143 | result: NavWithWildcards = [] 144 | if first_item is not None: 145 | if isinstance(first_item, str): 146 | self.seen_items.add(first_item) 147 | result.append(first_item) 148 | for item in section: 149 | assert item.tag == "li" 150 | out_title = item.text 151 | out_item = None 152 | 153 | children = _iter_children_without_tail(item) 154 | try: 155 | child = next(children) 156 | if not out_title and child.tag == "a": 157 | if link := child.get("href"): 158 | out_item = self._resolve_string_item(root, link) 159 | out_title = _unescape("".join(child.itertext())) 160 | child = next(children) 161 | if child.tag in _LIST_TAGS: 162 | out_item = self._list_element_to_nav( 163 | child, root, cast(Union[Wildcard, str, None], out_item) 164 | ) 165 | child = next(children) 166 | except StopIteration: 167 | error = "" 168 | else: 169 | error = f"Expected no more elements, but got {_to_short_string(child)}.\n" # noqa: E501 170 | if out_title is None: 171 | error += "Did not find any title specified." + _EXAMPLES 172 | elif out_item is None: 173 | if "*" in out_title: 174 | out_item = Wildcard(root, out_title) 175 | out_title = None 176 | else: 177 | error += ( 178 | "Did not find any item/section content specified." 179 | + _EXAMPLES 180 | ) 181 | if error: 182 | raise LiterateNavParseError(error, item) 183 | 184 | assert out_item is not None 185 | if ( 186 | type(out_item) in (str, list, DirectoryWildcard) 187 | and out_title is not None 188 | ): 189 | result.append({out_title: out_item}) 190 | else: 191 | result.append(out_item) 192 | return result 193 | 194 | def _resolve_string_item( 195 | self, root: str, link: str 196 | ) -> Union[Wildcard, str]: 197 | """Resolve a string item to a Wildcard or a string.""" 198 | parsed = urllib.parse.urlsplit(link) 199 | if parsed.scheme or parsed.netloc: 200 | return link 201 | 202 | abs_link = posixpath.normpath(posixpath.join(root, link)) 203 | self.seen_items.add(abs_link) 204 | if link.endswith("/") and self.globber.isdir(abs_link): 205 | return DirectoryWildcard(root, link) 206 | return abs_link 207 | 208 | def _resolve_wildcards( 209 | self, nav: NavWithWildcards, roots: RootStack = (".",) 210 | ) -> Nav: 211 | def can_recurse(new_root: str) -> bool: 212 | if new_root in roots: 213 | rec = " -> ".join(repr(r) for r in reversed((new_root, *roots))) 214 | self._warn(f"Disallowing recursion {rec}") 215 | return False 216 | return True 217 | 218 | # Ensure depth-first processing 219 | for entry in nav: 220 | if isinstance(entry, dict) and len(entry) == 1: 221 | [(key, val)] = entry.items() 222 | if isinstance(val, str): 223 | entry = val 224 | if isinstance(entry, str): 225 | self.seen_items.add(entry) 226 | 227 | resolved: Nav = [] 228 | for entry in nav: 229 | if isinstance(entry, dict) and len(entry) == 1: 230 | [(key, val)] = entry.items() 231 | new_val = None 232 | if isinstance(val, list): 233 | new_val = self._resolve_wildcards(val, roots) 234 | elif isinstance(val, DirectoryWildcard): 235 | new_val = ( 236 | self.markdown_to_nav((val.value, *roots)) 237 | if can_recurse(val.value) 238 | else val.fallback 239 | ) 240 | elif isinstance(val, Wildcard): 241 | new_val = ( 242 | self._resolve_wildcards([val], roots) or val.fallback 243 | ) 244 | else: 245 | new_val = val 246 | if new_val: 247 | resolved.append({key: new_val}) 248 | continue 249 | 250 | assert not isinstance(entry, (DirectoryWildcard, list, dict)) 251 | if not isinstance(entry, Wildcard): 252 | resolved.append(entry) 253 | continue 254 | 255 | any_matches = False 256 | for item in self.globber.glob(entry.value.rstrip("/")): 257 | any_matches = True 258 | if item in self.seen_items: 259 | continue 260 | if self.globber.isdir(item): 261 | title = mkdocs.utils.dirname_to_title( 262 | posixpath.basename(item) 263 | ) 264 | if subitems := self.markdown_to_nav((item, *roots)): 265 | resolved.append({title: subitems}) 266 | else: 267 | if entry.value.endswith("/"): 268 | continue 269 | resolved.append({None: item}) 270 | self.seen_items.add(item) 271 | if not any_matches and entry.fallback: 272 | resolved.append(entry.fallback) 273 | return resolved 274 | 275 | def resolve_yaml_nav(self, nav) -> Nav: 276 | """Resolve a YAML navigation structure.""" 277 | if not isinstance(nav, list): 278 | return nav 279 | return self._resolve_wildcards([self._resolve_yaml_nav(x) for x in nav]) 280 | 281 | def _resolve_yaml_nav(self, item) -> NavWithWildcardsItem: 282 | if isinstance(item, str) and "*" in item: 283 | return Wildcard("", item) 284 | if isinstance(item, dict): 285 | assert len(item) == 1 286 | [(key, val)] = item.items() 287 | if isinstance(val, list): 288 | return {key: [self._resolve_yaml_nav(x) for x in val]} 289 | if isinstance(val, str): 290 | if "*" in val: 291 | return {key: Wildcard("", val)} 292 | return {key: self._resolve_string_item("", val)} 293 | return {key: val} 294 | return item 295 | 296 | 297 | def _extract_nav_from_content( 298 | markdown_config: dict, markdown_content: str 299 | ) -> Union[etree.Element, None]: 300 | md = markdown.Markdown(**markdown_config) 301 | md.inlinePatterns.deregister("html", strict=False) 302 | md.inlinePatterns.deregister("entity", strict=False) 303 | preprocessor = _Preprocessor(md) 304 | preprocessor._register() 305 | treeprocessor = _Treeprocessor(md) 306 | treeprocessor._register() 307 | md.convert(markdown_content) 308 | return treeprocessor.nav 309 | 310 | 311 | class _Preprocessor(markdown.preprocessors.Preprocessor): 312 | nav_placeholder = None 313 | 314 | def run(self, lines: List[str]) -> List[str]: 315 | for i, line in enumerate(lines): 316 | if line.strip() == "": 317 | self.nav_placeholder = self.md.htmlStash.store("") 318 | lines[i] = self.nav_placeholder + "\n" 319 | return lines 320 | 321 | def _register(self) -> None: 322 | self.md.preprocessors.register(self, "mkdocs_autoapi", priority=25) 323 | 324 | 325 | class _Treeprocessor(markdown.treeprocessors.Treeprocessor): 326 | nav = None 327 | 328 | def run(self, root: etree.Element) -> None: 329 | preprocessor: _Preprocessor = self.md.preprocessors["mkdocs_autoapi"] # type: ignore[assignment] 330 | nav_placeholder = preprocessor.nav_placeholder 331 | items: Iterator[etree.Element] 332 | if nav_placeholder is not None: 333 | # Will look for the first list after the last . 334 | items = itertools.dropwhile( 335 | lambda el: el.text != nav_placeholder, root 336 | ) 337 | else: 338 | # Will look for the last list. 339 | items = reversed(root) 340 | for el in items: 341 | if el.tag in _LIST_TAGS: 342 | self.nav = copy.deepcopy(el) 343 | break 344 | 345 | def _register(self) -> None: 346 | self.md.treeprocessors.register(self, "mkdocs_autoapi", priority=19) 347 | 348 | 349 | _LIST_TAGS = ("ul", "ol") 350 | _EXAMPLES = """ 351 | Examples: 352 | * [Item title](item_content.md) 353 | * Section title 354 | * [Sub content](sub/content.md) 355 | * *.md 356 | """ 357 | 358 | 359 | def _iter_children_without_tail( 360 | element: etree.Element, 361 | ) -> Iterator[etree.Element]: 362 | for child in element: 363 | yield child 364 | if child.tail: 365 | message = f"Expected no text after {_to_short_string(child)}, but got {child.tail!r}." # noqa: E501 366 | raise LiterateNavParseError( 367 | message, 368 | element, 369 | ) 370 | 371 | 372 | def _to_short_string(el: etree.Element) -> str: 373 | el = copy.deepcopy(el) 374 | for child in el: 375 | if child: 376 | del child[:] 377 | child.text = "[...]" 378 | el.tail = None 379 | return etree.tostring(el, encoding="unicode") 380 | 381 | 382 | class LiterateNavParseError(exceptions.LiterateNavError): 383 | """An error occurred while parsing a literate nav.""" 384 | 385 | def __init__(self, message, el): 386 | """Initialize a LiterateNavParseError instance.""" 387 | super().__init__( 388 | message + "\nThe problematic item:\n\n" + _to_short_string(el) 389 | ) 390 | --------------------------------------------------------------------------------