├── tests ├── fixtures │ └── repo1 │ │ ├── README.md │ │ ├── src │ │ └── my_library │ │ │ ├── _private_mod.py │ │ │ ├── exclude_me.py │ │ │ ├── _private_submod │ │ │ ├── __init__.py │ │ │ └── mod.py │ │ │ ├── __init__.py │ │ │ └── submod │ │ │ ├── sub_submod.py │ │ │ └── __init__.py │ │ ├── docs │ │ ├── index.md │ │ └── about.md │ │ ├── pyproject.toml │ │ └── mkdocs.yml ├── test_nav_title_namespace.py └── test_mkdocs_api_autonav.py ├── src └── mkdocs_api_autonav │ ├── py.typed │ ├── __init__.py │ └── plugin.py ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE.md ├── TEST_FAIL_TEMPLATE.md └── workflows │ └── ci.yml ├── CONTRIBUTING.md ├── .pre-commit-config.yaml ├── LICENSE ├── .gitignore ├── pyproject.toml └── README.md /tests/fixtures/repo1/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/src/my_library/_private_mod.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/src/my_library/exclude_me.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/src/my_library/_private_submod/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/docs/index.md: -------------------------------------------------------------------------------- 1 | # Home 2 | 3 | Welcome to my docs. 4 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/docs/about.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | This is the about page. 4 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/src/my_library/__init__.py: -------------------------------------------------------------------------------- 1 | from ._private_submod.mod import func_in_private_submod 2 | 3 | __all__ = ["func_in_private_submod"] 4 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "my-library" 7 | version = "0.1.0" 8 | requires-python = ">=3.9" 9 | -------------------------------------------------------------------------------- /src/mkdocs_api_autonav/py.typed: -------------------------------------------------------------------------------- 1 | You may remove this file if you don't intend to add types to your package 2 | 3 | Details at: 4 | 5 | https://mypy.readthedocs.io/en/stable/installed_packages.html#creating-pep-561-compatible-packages 6 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/src/my_library/submod/sub_submod.py: -------------------------------------------------------------------------------- 1 | def groovy_func(name: str) -> None: 2 | """A function that prints a greeting. 3 | 4 | Parameters 5 | ---------- 6 | name : str 7 | A name. 8 | 9 | Returns 10 | ------- 11 | None 12 | """ 13 | print(f"Hello, {name}!") 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | commit-message: 10 | prefix: "ci(dependabot):" 11 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: My Library 2 | strict: True 3 | 4 | 5 | theme: 6 | name: material 7 | features: 8 | - navigation.expand 9 | - navigation.indexes 10 | 11 | plugins: 12 | - search 13 | - api-autonav: 14 | modules: [src/my_library] 15 | exclude: ["my_library.exclude_me"] 16 | - mkdocstrings: 17 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/src/my_library/_private_submod/mod.py: -------------------------------------------------------------------------------- 1 | def func_in_private_submod(x: int) -> str: 2 | """Public function in a private submodule. 3 | 4 | Parameters 5 | ---------- 6 | x : int 7 | An integer. 8 | 9 | Returns 10 | ------- 11 | str 12 | A string. 13 | """ 14 | return str(x) 15 | -------------------------------------------------------------------------------- /src/mkdocs_api_autonav/__init__.py: -------------------------------------------------------------------------------- 1 | """Autogenerate API docs with mkdocstrings, including nav.""" 2 | 3 | from importlib.metadata import PackageNotFoundError, version 4 | 5 | try: 6 | __version__ = version("mkdocs-api-autonav") 7 | except PackageNotFoundError: # pragma: no cover 8 | __version__ = "uninstalled" 9 | __author__ = "Talley Lambert" 10 | __email__ = "talley.lambert@gmail.com" 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * mkdocs-api-autonav version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /tests/fixtures/repo1/src/my_library/submod/__init__.py: -------------------------------------------------------------------------------- 1 | class PubCls: 2 | """A class defined in my_library.submod.__init__""" 3 | 4 | def method(self, x: int) -> str: 5 | """A method of PubCls. 6 | 7 | Parameters 8 | ---------- 9 | x : int 10 | An integer. 11 | 12 | Returns 13 | ------- 14 | str 15 | A string. 16 | """ 17 | return str(x) 18 | -------------------------------------------------------------------------------- /.github/TEST_FAIL_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "{{ env.TITLE }}" 3 | labels: [bug] 4 | --- 5 | The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC 6 | 7 | The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }} 8 | with commit: {{ sha }} 9 | 10 | Full run: https://github.com/{{ repo }}/actions/runs/{{ env.RUN_ID }} 11 | 12 | (This post will be updated if another test fails, as long as this issue remains open.) 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | PRs and contributions are welcome! 4 | 5 | ## Development Setup 6 | 7 | To get started with development, clone the repository and install the required dependencies: 8 | 9 | ```sh 10 | git clone https://github.com/tlambert03/mkdocs-api-autonav.git 11 | cd mkdocs-api-autonav 12 | uv sync 13 | ``` 14 | 15 | ### Testing 16 | 17 | ```sh 18 | uv run pytest 19 | ``` 20 | 21 | ### Run local docs fitures 22 | 23 | ```sh 24 | uv run --group docs mkdocs serve -f tests/fixtures/repo1/mkdocs.yml 25 | ``` 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autofix_commit_msg: "style(pre-commit.ci): auto fixes [...]" 4 | autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate" 5 | 6 | repos: 7 | - repo: https://github.com/abravalheri/validate-pyproject 8 | rev: v0.24.1 9 | hooks: 10 | - id: validate-pyproject 11 | 12 | - repo: https://github.com/crate-ci/typos 13 | rev: v1.32.0 14 | hooks: 15 | - id: typos 16 | args: [--force-exclude] # omitting --write-changes 17 | 18 | - repo: https://github.com/astral-sh/ruff-pre-commit 19 | rev: v0.11.10 20 | hooks: 21 | - id: ruff-check 22 | args: [--fix, --unsafe-fixes] 23 | - id: ruff-format 24 | 25 | - repo: https://github.com/pre-commit/mirrors-mypy 26 | rev: v1.15.0 27 | hooks: 28 | - id: mypy 29 | files: "^src/" 30 | additional_dependencies: 31 | - mkdocs >=1.6 32 | - mkdocstrings-python 33 | - types-PyYAML 34 | -------------------------------------------------------------------------------- /tests/test_nav_title_namespace.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mkdocs_api_autonav.plugin import AutoAPIPlugin, PluginConfig 4 | 5 | 6 | def make_plugin(show_full_namespace: bool) -> AutoAPIPlugin: 7 | plugin = AutoAPIPlugin() 8 | cfg = PluginConfig() 9 | cfg.show_full_namespace = show_full_namespace # pyright: ignore 10 | object.__setattr__(plugin, "config", cfg) 11 | return plugin 12 | 13 | 14 | @pytest.mark.parametrize("show_full_namespace", [True, False]) 15 | def test_nav_title_namespace(show_full_namespace: bool) -> None: 16 | # Simulate a module path 17 | parts = ("vllm", "distributed", "device_communicators", "cpu_communicator") 18 | plugin = make_plugin(show_full_namespace) 19 | md = plugin._module_markdown(parts) 20 | # Parse YAML front-matter 21 | title = md.split("---\n")[1].split("\n")[0].replace("title: ", "") 22 | expect = ( 23 | "vllm.distributed.device_communicators.cpu_communicator" 24 | if show_full_namespace 25 | else "cpu_communicator" 26 | ) 27 | assert title == expect 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Talley Lambert 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | .DS_Store 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # ruff 107 | .ruff_cache/ 108 | 109 | # IDE settings 110 | .vscode/ 111 | .idea/ 112 | uv.lock 113 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: [v*] 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v6 19 | - uses: astral-sh/setup-uv@v7 20 | - run: uv run pyright 21 | 22 | test: 23 | name: Test 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | os: [ubuntu-latest] 29 | resolution: ["highest", "lowest-direct"] 30 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 31 | include: 32 | - python-version: "3.10" 33 | resolution: "highest" 34 | os: macos-latest 35 | - python-version: "3.10" 36 | resolution: "highest" 37 | os: windows-latest 38 | steps: 39 | - uses: actions/checkout@v6 40 | - uses: astral-sh/setup-uv@v7 41 | - run: uv run --no-dev --group test pytest --color yes -v --cov --cov-report=xml 42 | env: 43 | UV_RESOLUTION: ${{ matrix.resolution }} 44 | UV_PYTHON: ${{ matrix.python-version }} 45 | PYTEST_ADDOPTS: ${{ matrix.resolution == 'lowest-direct' && '-W ignore' || '' }} 46 | - uses: codecov/codecov-action@v5 47 | with: 48 | token: ${{ secrets.CODECOV_TOKEN }} 49 | 50 | deploy: 51 | name: Deploy 52 | needs: test 53 | if: success() && startsWith(github.ref, 'refs/tags/') && github.event_name != 'schedule' 54 | runs-on: ubuntu-latest 55 | permissions: 56 | id-token: write 57 | contents: write 58 | steps: 59 | - uses: actions/checkout@v6 60 | with: 61 | fetch-depth: 0 62 | - uses: astral-sh/setup-uv@v7 63 | - run: uv build 64 | - uses: pypa/gh-action-pypi-publish@release/v1 65 | - uses: softprops/action-gh-release@v2 66 | with: 67 | generate_release_notes: true 68 | files: "./dist/*" 69 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # https://peps.python.org/pep-0517/ 2 | [build-system] 3 | requires = ["hatchling", "hatch-vcs"] 4 | build-backend = "hatchling.build" 5 | 6 | # https://hatch.pypa.io/latest/config/metadata/ 7 | [tool.hatch.version] 8 | source = "vcs" 9 | 10 | # https://peps.python.org/pep-0621/ 11 | [project] 12 | name = "mkdocs-api-autonav" 13 | dynamic = ["version"] 14 | description = "Autogenerate API docs with mkdocstrings, including nav" 15 | readme = "README.md" 16 | requires-python = ">=3.9" 17 | license = { text = "BSD-3-Clause" } 18 | authors = [{ name = "Talley Lambert", email = "talley.lambert@gmail.com" }] 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "License :: OSI Approved :: BSD License", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: 3.14", 29 | "Typing :: Typed", 30 | ] 31 | dependencies = ["mkdocs>=1.6", "mkdocstrings-python>=1.11.0", "pyyaml >=5"] 32 | 33 | [project.entry-points."mkdocs.plugins"] 34 | api-autonav = "mkdocs_api_autonav.plugin:AutoAPIPlugin" 35 | 36 | [project.urls] 37 | homepage = "https://github.com/tlambert03/mkdocs-api-autonav" 38 | repository = "https://github.com/tlambert03/mkdocs-api-autonav" 39 | 40 | [dependency-groups] 41 | docs = ["mkdocs-api-autonav", "mkdocs-material>=9.6.16", "mkdocstrings>=0.30.0"] 42 | test = [ 43 | "pytest>=8.3.4", 44 | "pytest-cov>=6.0.0", 45 | "mkdocs-awesome-nav>=3.1.1 ; python_full_version >= '3.10' and python_full_version < '3.14'", 46 | ] 47 | dev = [ 48 | { include-group = "test" }, 49 | "ipython>=8.18.1", 50 | "mypy>=1.14.1", 51 | "pdbpp>=0.10.3; sys_platform == 'darwin'", 52 | "pre-commit-uv>=4.1.0", 53 | "pyright>=1.1.393", 54 | "rich>=13.9.4", 55 | "ruff>=0.9.3", 56 | "types-pyyaml>=6.0.12.20250516", 57 | ] 58 | 59 | # https://docs.astral.sh/ruff 60 | [tool.ruff] 61 | line-length = 88 62 | target-version = "py39" 63 | src = ["src"] 64 | 65 | # https://docs.astral.sh/ruff/rules 66 | [tool.ruff.lint] 67 | pydocstyle = { convention = "numpy" } 68 | select = [ 69 | "E", # style errors 70 | "W", # style warnings 71 | "F", # flakes 72 | "D", # pydocstyle 73 | "D417", # Missing argument descriptions in Docstrings 74 | "I", # isort 75 | "UP", # pyupgrade 76 | "C4", # flake8-comprehensions 77 | "B", # flake8-bugbear 78 | "A001", # flake8-builtins 79 | "RUF", # ruff-specific rules 80 | "TC", # flake8-type-checking 81 | "TID", # flake8-tidy-imports 82 | ] 83 | ignore = [ 84 | "D401", # First line should be in imperative mood (remove to opt in) 85 | ] 86 | 87 | [tool.ruff.lint.per-file-ignores] 88 | "tests/*.py" = ["D", "S"] 89 | 90 | # https://docs.astral.sh/ruff/formatter/ 91 | [tool.ruff.format] 92 | docstring-code-format = true 93 | skip-magic-trailing-comma = false # default is false 94 | 95 | # https://mypy.readthedocs.io/en/stable/config_file.html 96 | [tool.mypy] 97 | files = "src/**/" 98 | strict = true 99 | disallow_any_generics = false 100 | disallow_subclassing_any = false 101 | show_error_codes = true 102 | pretty = true 103 | 104 | # https://docs.pytest.org/ 105 | [tool.pytest.ini_options] 106 | minversion = "7.0" 107 | testpaths = ["tests"] 108 | filterwarnings = ["error"] 109 | 110 | # https://coverage.readthedocs.io/ 111 | [tool.coverage.report] 112 | show_missing = true 113 | exclude_lines = [ 114 | "pragma: no cover", 115 | "if TYPE_CHECKING:", 116 | "@overload", 117 | "except ImportError", 118 | "\\.\\.\\.", 119 | "raise NotImplementedError()", 120 | "pass", 121 | ] 122 | 123 | [tool.coverage.run] 124 | source = ["mkdocs_api_autonav"] 125 | 126 | [tool.pyright] 127 | include = ["src"] 128 | pythonVersion = "3.9" 129 | venv = ".venv" 130 | 131 | [tool.uv.workspace] 132 | members = ["tests/fixtures/repo1"] 133 | 134 | [tool.uv.sources] 135 | my_library = { workspace = true } 136 | -------------------------------------------------------------------------------- /tests/test_mkdocs_api_autonav.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import shutil 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING, Any 6 | 7 | import pytest 8 | import yaml 9 | from mkdocs.exceptions import Abort 10 | from pytest import MonkeyPatch 11 | 12 | from mkdocs_api_autonav.plugin import PluginConfig, _iter_modules 13 | 14 | if TYPE_CHECKING: 15 | from _pytest.logging import LogCaptureFixture 16 | 17 | FIXTURES = Path(__file__).parent / "fixtures" 18 | REPO1 = FIXTURES / "repo1" 19 | 20 | NAV_SECTION = PluginConfig.nav_section_title.default 21 | API_URI = PluginConfig.api_root_uri.default 22 | 23 | 24 | def _build_command(config_file: str) -> None: 25 | from mkdocs import config 26 | from mkdocs.commands import build 27 | 28 | cfg = config.load_config(config_file) 29 | cfg.plugins.on_startup(command="build", dirty=False) 30 | try: 31 | build.build(cfg, dirty=False) 32 | finally: 33 | cfg.plugins.on_shutdown() 34 | 35 | 36 | def cfg_dict(strict: bool = False, **kwargs: Any) -> dict: 37 | return { 38 | "site_name": "My Library", 39 | "strict": strict, 40 | "plugins": [ 41 | "search", 42 | {"mkdocstrings": {}}, 43 | { 44 | "api-autonav": { 45 | "modules": ["src/my_library"], 46 | "exclude": ["my_library.exclude_me"], 47 | **kwargs, 48 | } 49 | }, 50 | ], 51 | } 52 | 53 | 54 | @pytest.fixture 55 | def repo1(tmp_path: Path, monkeypatch: MonkeyPatch) -> Path: 56 | repo = tmp_path / "repo" 57 | shutil.copytree(REPO1, repo) 58 | monkeypatch.syspath_prepend(str(repo / "src")) 59 | return repo 60 | 61 | 62 | def test_build(repo1: Path) -> None: 63 | mkdocs_yml = repo1 / "mkdocs.yml" 64 | mkdocs_yml.write_text(yaml.safe_dump(cfg_dict())) 65 | _build_command(str(mkdocs_yml)) 66 | 67 | assert (ref := repo1 / "site" / "reference").is_dir() 68 | assert (lib := ref / "my_library").is_dir() 69 | assert (lib / "index.html").is_file() 70 | assert (sub_mod := lib / "submod").is_dir() 71 | assert (sub_mod / "index.html").is_file() 72 | assert (sub_sub := sub_mod / "sub_submod").is_dir() 73 | assert (sub_sub / "index.html").is_file() 74 | assert not any(lib.rglob("*exclude_me*")) 75 | 76 | 77 | def test_build_exclude_re(repo1: Path) -> None: 78 | mkdocs_yml = repo1 / "mkdocs.yml" 79 | cfg = cfg_dict() 80 | cfg["plugins"][2]["api-autonav"]["exclude"] = ["re:.*xcl.*"] 81 | mkdocs_yml.write_text(yaml.safe_dump(cfg)) 82 | _build_command(str(mkdocs_yml)) 83 | 84 | assert (ref := repo1 / "site" / "reference").is_dir() 85 | assert (lib := ref / "my_library").is_dir() 86 | assert not any(lib.rglob("*exclude_me*")) 87 | 88 | 89 | def test_sorting(repo1: Path) -> None: 90 | package = repo1 / "src" / "my_library" 91 | (package / "z_submod.py").touch() 92 | (package / "a_submod.py").touch() 93 | modules = [ 94 | x 95 | for x, *_ in _iter_modules(repo1 / "src" / "my_library", str(repo1), "skip") 96 | if not any(part.startswith("_") for part in x) 97 | ] 98 | assert modules == [ 99 | ("my_library",), 100 | ("my_library", "a_submod"), 101 | ("my_library", "exclude_me"), 102 | ("my_library", "submod"), 103 | ("my_library", "submod", "sub_submod"), 104 | ("my_library", "z_submod"), 105 | ] 106 | 107 | 108 | def test_build_without_mkdocstrings(repo1: Path, caplog: LogCaptureFixture) -> None: 109 | cfg = cfg_dict() 110 | cfg["plugins"].remove({"mkdocstrings": {}}) 111 | mkdocs_yml = repo1 / "mkdocs.yml" 112 | mkdocs_yml.write_text(yaml.safe_dump(cfg)) 113 | _build_command(str(mkdocs_yml)) 114 | 115 | assert any( 116 | "api-autonav: 'mkdocstrings' wasn't found in the plugins list" in line 117 | for line in caplog.messages 118 | ) 119 | 120 | # strict mode should error out 121 | cfg["strict"] = True 122 | mkdocs_yml.write_text(yaml.safe_dump(cfg)) 123 | 124 | with pytest.raises(Abort): 125 | _build_command(str(mkdocs_yml)) 126 | 127 | 128 | NAV_CASES: list[tuple[bool, dict]] = [ 129 | (True, {}), 130 | (True, {"nav": ["index.md"]}), 131 | (True, {"nav": [{"Home": "index.md"}]}), 132 | (True, {"nav": ["index.md", NAV_SECTION]}), 133 | (True, {"nav": ["index.md", {NAV_SECTION: API_URI}]}), 134 | (False, {"nav": ["index.md", {NAV_SECTION: "differentname/"}]}), 135 | (False, {"nav": ["index.md", {NAV_SECTION: ["some_file.md"]}]}), 136 | ] 137 | 138 | 139 | @pytest.mark.parametrize("show_full_namespace", [True, False]) 140 | @pytest.mark.parametrize("strict, nav", NAV_CASES) 141 | def test_build_with_nav( 142 | repo1: Path, 143 | strict: bool, 144 | nav: dict, 145 | show_full_namespace: bool, 146 | caplog: LogCaptureFixture, 147 | ) -> None: 148 | cfg_with_nav = { 149 | **cfg_dict(strict=strict, show_full_namespace=show_full_namespace), 150 | **nav, 151 | } 152 | mkdocs_yml = repo1 / "mkdocs.yml" 153 | mkdocs_yml.write_text(yaml.safe_dump(cfg_with_nav)) 154 | _build_command(str(mkdocs_yml)) 155 | 156 | if nav_list := nav.get("nav"): 157 | expect_message = bool( 158 | isinstance(nav_dict := nav_list[-1], dict) 159 | and (nav_sec := nav_dict.get(NAV_SECTION)) 160 | and nav_sec != API_URI 161 | ) 162 | else: 163 | expect_message = False 164 | assert bool(caplog.messages) == expect_message 165 | 166 | assert (ref := repo1 / "site" / "reference").is_dir() 167 | assert (lib := ref / "my_library").is_dir() 168 | assert (lib / "index.html").is_file() 169 | assert (sub_mod := lib / "submod").is_dir() 170 | assert (sub_mod / "index.html").is_file() 171 | assert (sub_sub := sub_mod / "sub_submod").is_dir() 172 | assert (sub_sub / "index.html").is_file() 173 | 174 | 175 | def test_build_with_nav_conflict(repo1: Path, caplog: LogCaptureFixture) -> None: 176 | cfg = cfg_dict(strict=True) 177 | cfg_with_nav = {**cfg, "nav": ["index.md", {NAV_SECTION: "differentname/"}]} 178 | mkdocs_yml = repo1 / "mkdocs.yml" 179 | mkdocs_yml.write_text(yaml.safe_dump(cfg_with_nav)) 180 | with pytest.raises(Abort): 181 | _build_command(str(mkdocs_yml)) 182 | 183 | assert caplog.messages[0].startswith( 184 | "api-autonav: Encountered pre-existing navigation section" 185 | ) 186 | 187 | 188 | def test_warns_on_bad_structure(repo1: Path, caplog: LogCaptureFixture) -> None: 189 | cfg = cfg_dict(strict=True) 190 | cfg["plugins"][2]["api-autonav"]["on_implicit_namespace_package"] = "raise" 191 | mkdocs_yml = repo1 / "mkdocs.yml" 192 | mkdocs_yml.write_text(yaml.safe_dump(cfg)) 193 | # add a submodule that doesn't have an __init__.py 194 | bad_sub = repo1 / "src" / "my_library" / "bad_submod" 195 | bad_sub.mkdir(parents=True) 196 | (bad_sub / "misses_init.py").touch() 197 | 198 | # by default, should raise a helpful error 199 | with pytest.raises(RuntimeError, match="Implicit namespace package"): 200 | _build_command(str(mkdocs_yml)) 201 | 202 | caplog.clear() 203 | cfg["plugins"][2]["api-autonav"]["on_implicit_namespace_package"] = "warn" 204 | mkdocs_yml.write_text(yaml.safe_dump(cfg)) 205 | with pytest.raises(Abort): 206 | _build_command(str(mkdocs_yml)) 207 | assert any( 208 | "api-autonav: Skipping implicit namespace package" in line 209 | for line in caplog.messages 210 | ) 211 | 212 | caplog.clear() 213 | caplog.set_level("INFO") 214 | cfg["plugins"][2]["api-autonav"]["on_implicit_namespace_package"] = "skip" 215 | mkdocs_yml.write_text(yaml.safe_dump(cfg)) 216 | _build_command(str(mkdocs_yml)) 217 | assert any( 218 | "api-autonav: Skipping implicit namespace package" in line 219 | for line in caplog.messages 220 | ) 221 | 222 | 223 | @pytest.mark.parametrize("strict, nav", NAV_CASES) 224 | # duplicate my_library to my_library2 and add two top level submodules to config 225 | def test_multi_package(repo1: Path, strict: bool, nav: dict) -> None: 226 | cfg_with_nav = {**cfg_dict(strict=strict), **nav} 227 | 228 | # add another package 229 | shutil.copytree(repo1 / "src" / "my_library", repo1 / "src" / "my_library2") 230 | cfg_with_nav["plugins"][2]["api-autonav"]["modules"].append("src/my_library2") 231 | 232 | # this is important to trigger a possible bug: 233 | mkdocs_yml = repo1 / "mkdocs.yml" 234 | mkdocs_yml.write_text(yaml.safe_dump(cfg_with_nav)) 235 | _build_command(str(mkdocs_yml)) 236 | assert (repo1 / "site" / "reference").is_dir() 237 | 238 | 239 | def test_index_py_module(repo1: Path) -> None: 240 | """Test the edge case of a module named index.py""" 241 | cfg = cfg_dict() 242 | repo1.joinpath("src", "my_library", "index.py").touch() 243 | mkdocs_yml = repo1 / "mkdocs.yml" 244 | mkdocs_yml.write_text(yaml.safe_dump(cfg)) 245 | _build_command(str(mkdocs_yml)) 246 | 247 | assert (ref := repo1 / "site" / "reference").is_dir() 248 | assert (lib := ref / "my_library").is_dir() 249 | assert (lib / "index.html").is_file() 250 | assert (sub_mod := lib / "index_py").is_dir() 251 | assert (sub_mod / "index.html").is_file() 252 | 253 | 254 | def test_awesome_nav_compat(repo1: Path) -> None: 255 | pytest.importorskip("mkdocs_awesome_nav") 256 | nav = repo1.joinpath("docs", ".nav.yml") 257 | nav.write_text('nav:\n - "index.md"\n') 258 | 259 | cfg = cfg_dict() 260 | cfg["plugins"].insert(0, "awesome-nav") 261 | 262 | mkdocs_yml = repo1 / "mkdocs.yml" 263 | mkdocs_yml.write_text(yaml.safe_dump(cfg)) 264 | _build_command(str(mkdocs_yml)) 265 | assert (ref := repo1 / "site" / "reference").is_dir() 266 | assert (lib := ref / "my_library").is_dir() 267 | assert (lib / "index.html").is_file() 268 | assert (lib / "submod").is_dir() 269 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mkdocs-api-autonav 2 | 3 | [![License](https://img.shields.io/pypi/l/mkdocs-api-autonav.svg?color=green)](https://github.com/tlambert03/mkdocs-api-autonav/raw/main/LICENSE) 4 | [![PyPI](https://img.shields.io/pypi/v/mkdocs-api-autonav.svg?color=green)](https://pypi.org/project/mkdocs-api-autonav) 5 | [![Python Version](https://img.shields.io/pypi/pyversions/mkdocs-api-autonav.svg?color=green)](https://python.org) 6 | [![CI](https://github.com/tlambert03/mkdocs-api-autonav/actions/workflows/ci.yml/badge.svg)](https://github.com/tlambert03/mkdocs-api-autonav/actions/workflows/ci.yml) 7 | [![codecov](https://codecov.io/gh/tlambert03/mkdocs-api-autonav/branch/main/graph/badge.svg)](https://codecov.io/gh/tlambert03/mkdocs-api-autonav) 8 | 9 | Autogenerate API reference including navigation for all submodules, with 10 | [mkdocstrings](https://github.com/mkdocstrings/mkdocstrings). 11 | 12 | *(removes the need for using a [custom script](#why-this-plugin) alongside `mkdocs-gen-files` and `literate-nav`)* 13 | 14 | ## Quick Start 15 | 16 | ```shell 17 | pip install mkdocs-api-autonav 18 | ``` 19 | 20 | ```yaml 21 | # mkdocs.yml 22 | site_name: "My Library" 23 | 24 | plugins: 25 | - search 26 | - mkdocstrings 27 | - api-autonav: 28 | modules: ['src/my_library'] 29 | ``` 30 | 31 | > [!IMPORTANT] 32 | > This plugin depends on `mkdocs>=1.6` (*Released: Apr 20, 2024*) 33 | 34 | ### Configuration 35 | 36 | Here are all the configurables, along with their default values. 37 | 38 | ```yaml 39 | plugins: 40 | - api-autonav: 41 | modules: [] 42 | module_options: {} 43 | nav_section_title: "API Reference" 44 | api_root_uri: "reference" 45 | nav_item_prefix: "" 46 | exclude_private: true 47 | show_full_namespace: false 48 | on_implicit_namespace_package: "warn" 49 | ``` 50 | 51 | - **`modules`** (`list[str]`) - List of paths to Python modules to include in the 52 | navigation, relative to the project root. This is the only required 53 | configuration. (e.g., `["src/package"]`) 54 | - **`module_options`** (`dict[str, dict]`) - Dictionary of local options to pass 55 | to `mkdocstrings` for specific modules. The keys are python identifiers or a 56 | regex pattern to match (e.g., `package.module` or `.*\.some_module`) and the values are 57 | dictionaries of [local options to pass to 58 | `mkdocstrings`](https://mkdocstrings.github.io/python/usage/#globallocal-options) 59 | for that specific module. To specify options for *all* modules, use the 60 | `.*` regex pattern (or add it to your global mkdocstrings config) 61 | 62 | ```yaml 63 | plugins: 64 | - api-autonav: 65 | modules: ['src/package'] 66 | module_options: 67 | package.submodule: 68 | docstring_style: google 69 | show_signature: false 70 | ".*": 71 | heading_level: 1 72 | show_symbol_type_heading: true 73 | ``` 74 | 75 | Note that `{"heading_level": 1}` is set by default, since it is a very useful 76 | default... but can be overridden if you don't want the module path to be the 77 | `h1` heading for the page. 78 | 79 | - **`exclude`** (`list[str]`) - List of module paths or patterns to exclude. Can 80 | be specified as exact module paths (e.g., `["package.module"]`), which will 81 | also exclude any submodules, or as regex patterns prefixed with `'re:'` (e.g., 82 | `["re:package\\.utils\\..*"]`). Regex patterns are matched against the full 83 | module path. 84 | - **`nav_section_title`** (`str`) - Title for the API reference section as it 85 | appears in the navigation. Default is "API Reference" 86 | - **`api_root_uri`** (`str`) - Root folder for api docs in the generated site. 87 | This determines the url path for the API documentation. Default is "reference" 88 | - **`nav_item_prefix`** (`str`) - A prefix to add to each module name in the 89 | navigation. By default, renders a `[mod]` badge before each module. Set to 90 | the empty string to disable this. 91 | - **`exclude_private`** (`bool`) - Exclude modules that start with an 92 | underscore. `True` by default. 93 | - **`show_full_namespace`** (`bool`) - Show the full namespace in the navigation 94 | title (as opposed to just the leaf module name). `False` by default (to avoid 95 | clipping of long, nested module names). The full module path is still shown 96 | as the header of each page. 97 | - **`on_implicit_namespace_package`** (`str`) - What to do when an [implicit 98 | namespace package](https://peps.python.org/pep-0420/) is found. An "implicit 99 | namespace package" is a directory that contains python files, but no 100 | `__init__.py` file; these will likely cause downstream errors for mkdocstrings. 101 | Options include: 102 | - `"raise"` - immediately stop and raise an error 103 | - `"warn"` - log a warning, and continue (omitting the namespace package) 104 | - `"skip"` - silently omit the namespace package and its children 105 | 106 | ### Integration with nav 107 | 108 | No `nav` configuration is required in `mkdocs.yml`, but in most cases you will want to 109 | have one anyway. Here are the rules for how this plugin integrates with your 110 | existing `nav` configuration. 111 | 112 | 1. **If `` exists and is explicitly referenced as a string** 113 | 114 | If your nav contains a string entry matching the `api-autonav.nav_section_title` 115 | (e.g., - `"API Reference"`), the plugin replaces it with a structured 116 | navigation dictionary containing the generated API documentation. This 117 | can be used to reposition the API section in the navigation. 118 | 119 | 1. **If `` exists as a dictionary with a single string value** 120 | 121 | If the API section is defined as `{ api-autonav.nav_section_title: "some/path" }` 122 | (e.g., - `"API Reference": "reference/"`), the plugin verifies that 123 | `"some/path"` matches the expected `api-autonav.api_root_uri` directory where 124 | API documentation is generated. If it matches, the string is replaced with 125 | the structured API navigation. Otherwise, an error is logged, and no changes 126 | are made. This can be used to reposition the API section in the navigation, 127 | and also to add additional items to the API section, for example, using 128 | `literate-nav` to autodetect other markdown files in your 129 | `docs/` directory. 130 | 131 | 1. **If `` is a dictionary containing a list of items** 132 | 133 | If the API section is defined as `{ api-autonav.nav_section_title: [...] }`, the plugin 134 | appends its generated navigation structure to the existing list. This 135 | can be used to add additional items to the API section. 136 | 137 | 1. **If `` is not found in nav** 138 | 139 | If no API section is found in the existing nav, the plugin appends a new 140 | section at the end of the nav list with the generated API navigation. 141 | 142 | ### Integration with mkdocs-awesome-nav 143 | 144 | [`mkdocs-awesome-nav`](https://lukasgeiter.github.io/mkdocs-awesome-nav/) 145 | ["completely discards the navigation that MkDocs and other plugins 146 | generate"](https://lukasgeiter.github.io/mkdocs-awesome-nav/philosophy/), and as 147 | such requires special consideration. Currently, the only way we integrate with 148 | `mkdocs-awesome-nav` is to add the generated API navigation to the end of the 149 | `nav` list, with the name `nav_section_title` from your config. 150 | 151 | ## Configuring Docstrings 152 | 153 | Since mkdocstrings is used to generate the API documentation, you can configure 154 | the docstrings as usual, following the [mkdocstrings 155 | documentation](https://mkdocstrings.github.io/python/usage/). 156 | 157 | I find the following settings to be particularly worth considering: 158 | 159 | ```yaml 160 | plugins: 161 | - mkdocstrings: 162 | handlers: 163 | python: 164 | inventories: 165 | - https://docs.python.org/3/objects.inv 166 | options: 167 | docstring_section_style: list # or "table" 168 | docstring_style: "numpy" 169 | filters: ["!^_"] 170 | heading_level: 1 171 | merge_init_into_class: true 172 | parameter_headings: true 173 | separate_signature: true 174 | show_root_heading: true 175 | show_signature_annotations: true 176 | show_symbol_type_heading: true 177 | show_symbol_type_toc: true 178 | summary: true 179 | ``` 180 | 181 | If you *only* want these settings to apply to api modules, then you can use a special 182 | regex of `".*"` in the api-autonav `module_options` config, for example: 183 | 184 | ```yaml 185 | plugins: 186 | - api-autonav: 187 | module_options: 188 | ".*": 189 | show_symbol_type_heading: true 190 | show_symbol_type_toc: true 191 | heading_level: 1 192 | ``` 193 | 194 | ## Mkdocs-material suggestions 195 | 196 | When working with mkdocs-material, use [`theme.features: 197 | ['navigation.indexes']`](https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/#section-index-pages) to 198 | allow the module docs itself to be toggleable (rather than duplicated just 199 | inside the section): 200 | 201 | ```yaml 202 | theme: 203 | name: material 204 | features: 205 | - navigation.indexes 206 | ``` 207 | 208 | | with `navigation.indexes` | without | 209 | |---|---| 210 | | with | without | 211 | 212 | ## Why this plugin? 213 | 214 | I very frequently find myself using three plugins in conjunction to generate API 215 | documentation for my projects. 216 | 217 | - [mkdocstrings](https://github.com/mkdocstrings/mkdocstrings) with 218 | [mkdocstrings-python](https://github.com/mkdocstrings/python) - to generate 219 | the API documentation using mkdocstrings `::: ` directives. 220 | - [mkdocs-gen-files](https://github.com/oprypin/mkdocs-gen-files) - Along with a script to look through my `src` folder to 221 | generate virtual files (including just the mkdocstrings directives) for each 222 | (sub-)module in the project. 223 | - [literate-nav](https://github.com/oprypin/mkdocs-literate-nav) - To consume a virtual `SUMMARY.md` file generated using 224 | `mkdocs-gen-files` in the previous step, and generate a navigation structure that 225 | mirrors the module structure. 226 | 227 | > [!NOTE] 228 | > This pattern was mostly borrowed/inspired by the 229 | > [`gen_ref_nav.py` example in mkdocstrings](https://github.com/mkdocstrings/mkdocstrings/blob/6ef141222d0b5ad47ced9049472243cf5887ec0e/scripts/gen_ref_nav.py), 230 | > created by [@pawamoy](https://github.com/pawamoy) 231 | 232 | This requires copying the same script and configuring three different plugins. 233 | All I *really* want to do is point to the top level module(s) in my project 234 | and have the API documentation generated for all submodules, with navigation 235 | matching the module structure. 236 | 237 | This plugin does that, using lower-level plugin APIs 238 | ([`File.generated`](https://www.mkdocs.org/dev-guide/api/#mkdocs.structure.files.File.generated)) 239 | to avoid the need for `mkdocs-gen-files` and `literate-nav`. (Those plugins are 240 | fantastic, but are more than what was necessary for this specific task). 241 | 242 | It doesn't currently leave a ton of room for configuration, so it's mostly 243 | designed for those who want to document their *entire* public API. (I find 244 | it can actually be a useful way to remind myself of what I've actually 245 | exposed and omitted from the public API). 246 | 247 | ## Alternatives 248 | 249 | Here are some other plugins that support automatic generation of API docs (and 250 | why I ultimately decided not to use them.) 251 | 252 | - [**mkapi**](https://github.com/daizutabi/mkapi) - based on [astdoc](https://github.com/daizutabi/astdoc) instead of [mkdocstrings](https://github.com/mkdocstrings/mkdocstrings)... (and I wanted to stay within the mkdocstrings ecosystem) 253 | - [**mkdocs-autoapi**](https://github.com/jcayers20/mkdocs-autoapi) - also uses mkdocstrings (like this plugin), but vendors two other plugins ([mkdocs-gen-files](https://github.com/oprypin/mkdocs-gen-files) and [literate-nav](https://github.com/oprypin/mkdocs-literate-nav)) so as to support the [original pattern from @pawamoy's recipe](https://github.com/mkdocstrings/mkdocstrings/blob/6ef141222d0b5ad47ced9049472243cf5887ec0e/scripts/gen_ref_nav.py), made unnecessary by [newer mkdocs APIs](https://www.mkdocs.org/dev-guide/api/#mkdocs.structure.files.File.generated) 254 | -------------------------------------------------------------------------------- /src/mkdocs_api_autonav/plugin.py: -------------------------------------------------------------------------------- 1 | """api-autonav plugin for MkDocs.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from dataclasses import dataclass, field 7 | from pathlib import Path 8 | from textwrap import dedent, indent 9 | from typing import TYPE_CHECKING, Literal, cast 10 | 11 | import mkdocs.config.config_options as opt 12 | import yaml 13 | from mkdocs.config import Config 14 | from mkdocs.config.defaults import get_schema 15 | from mkdocs.plugins import BasePlugin, get_plugin_logger 16 | from mkdocs.structure.files import File, Files 17 | from mkdocs.structure.nav import Section 18 | from mkdocs.structure.pages import Page 19 | 20 | if TYPE_CHECKING: 21 | from collections.abc import Iterator, Sequence 22 | 23 | from mkdocs.config.config_options import Plugins 24 | from mkdocs.config.defaults import MkDocsConfig 25 | from mkdocs.structure import StructureItem 26 | from mkdocs.structure.nav import Navigation 27 | 28 | WarnRaiseSkip = Literal["warn", "raise", "skip"] 29 | 30 | 31 | PLUGIN_NAME = "api-autonav" # must match [project.entry-points."mkdocs.plugins"] 32 | 33 | # empty code block that renders as [mod] symbol 34 | MOD_SYMBOL = '' 35 | 36 | logger = get_plugin_logger(PLUGIN_NAME) 37 | 38 | 39 | class PluginConfig(Config): # type: ignore [no-untyped-call] 40 | """Our configuration options.""" 41 | 42 | modules = opt.ListOfPaths() 43 | """List of paths to Python modules to include in the navigation. (e.g. ['src/package']).""" # noqa 44 | module_options = opt.Type(dict, default={}) 45 | """Dictionary of options for each module. The keys are module identifiers, and the values are [options for mkdocstrings-python](https://mkdocstrings.github.io/python/usage/#globallocal-options).""" # noqa 46 | exclude = opt.ListOfItems[str](opt.Type(str), default=[]) 47 | """List of module paths or patterns to exclude (e.g. ['package.module', 're:package\\..*_utils']).""" # noqa 48 | nav_section_title = opt.Type(str, default="API Reference") 49 | """Title for the API reference section as it appears in the navigation.""" 50 | api_root_uri = opt.Type(str, default="reference") 51 | """Root folder for api docs in the generated site.""" 52 | nav_item_prefix = opt.Type(str, default=MOD_SYMBOL) 53 | """A prefix to add to each module name in the navigation.""" 54 | exclude_private = opt.Type(bool, default=True) 55 | """Exclude modules that start with an underscore.""" 56 | show_full_namespace = opt.Type(bool, default=False) 57 | """Show the full namespace for each module in the navigation. If false, only the module name will be shown.""" # noqa 58 | on_implicit_namespace_package = opt.Choice( 59 | default="warn", choices=["raise", "warn", "skip"] 60 | ) 61 | """What to do when encountering an implicit namespace package.""" 62 | 63 | 64 | class AutoAPIPlugin(BasePlugin[PluginConfig]): # type: ignore [no-untyped-call] 65 | """API docs with navigation plugin for MkDocs.""" 66 | 67 | nav: _NavNode 68 | _uses_awesome_nav: bool = False 69 | 70 | def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None: 71 | """First event called on build. Run right after the user config is loaded.""" 72 | # ensure that mkdocstrings is in the plugins list 73 | # if not, add it and log a warning 74 | self._uses_awesome_nav = "awesome-nav" in config.plugins 75 | 76 | if "mkdocstrings" not in config.plugins: 77 | for name, option in get_schema(): 78 | if name == "plugins": 79 | plugins_option = cast("Plugins", option) 80 | plugins_option.load_plugin_with_namespace("mkdocstrings", {}) 81 | logger.warning( 82 | "'mkdocstrings' wasn't found in the plugins list. " 83 | f"It has been added automatically by {PLUGIN_NAME}.\nPlease " 84 | "ensure that 'mkdocstrings' is added to the plugins list." 85 | ) 86 | # this adds the mkdocstrings plugin to the config['plugins'] 87 | # but should we add it to config['plugins'] explicitly? 88 | break 89 | 90 | self.nav = _NavNode( 91 | name_prefix=self.config.nav_item_prefix, 92 | title=self.config.nav_section_title, 93 | show_full_namespace=self.config.show_full_namespace, 94 | ) 95 | return None 96 | 97 | def _module_markdown(self, parts: tuple[str, ...]) -> str: 98 | """Create the content for the virtual file. 99 | 100 | Example 101 | ------- 102 | >>> parts = ("top_module", "sub", "sub_sub") 103 | >>> print(_make_content(parts)) 104 | --- 105 | title: top_module.sub.sub_sub 106 | --- 107 | 108 | ::: top_module.sub.sub_sub 109 | """ 110 | mod_identifier = ".".join(parts) # top_module.sub.sub_sub 111 | options = {"heading_level": 1} # very useful default... but can be overridden 112 | for option in self.config.module_options: 113 | if re.match(option, mod_identifier): 114 | # if the option is a regex, it matches the module identifier 115 | options.update(self.config.module_options[option]) 116 | 117 | # create the actual markdown that will go into the virtual file 118 | if int(options.get("heading_level", 1)) > 1: 119 | h1 = f"# {mod_identifier}" 120 | else: 121 | h1 = "" 122 | options.setdefault("show_root_heading", True) 123 | 124 | md = f""" 125 | --- 126 | title: {self._display_title(parts)} 127 | --- 128 | {h1} 129 | 130 | ::: {mod_identifier} 131 | """ 132 | 133 | options_str = yaml.dump({"options": options}, default_flow_style=False) 134 | md = dedent(md).lstrip() + indent(options_str, " ") 135 | return md 136 | 137 | def on_files(self, files: Files, /, *, config: MkDocsConfig) -> None: 138 | """Called after the files collection is populated from the `docs_dir`. 139 | 140 | Here we generate the virtual files that will be used to render the API 141 | (each ) 142 | """ 143 | exclude_private = self.config.exclude_private 144 | exclude_patterns: list[re.Pattern] = [] 145 | exclude_paths: list[str] = [] 146 | 147 | # Preprocess exclude patterns 148 | for pattern in self.config.exclude: 149 | if pattern.startswith("re:"): 150 | # Regex pattern 151 | try: 152 | exclude_patterns.append(re.compile(pattern[3:])) 153 | except re.error: # pragma: no cover 154 | logger.error("Invalid regex pattern: %s", pattern[3:]) 155 | else: 156 | # Direct module path 157 | exclude_paths.append(pattern) 158 | 159 | # for each top-level module specified in plugins.api-autonav.modules 160 | for module in self.config.modules: 161 | # iterate (recursively) over all modules in the package 162 | for name_parts, docs_path in _iter_modules( 163 | module, 164 | self.config.api_root_uri, 165 | self.config.on_implicit_namespace_package, # type: ignore [arg-type] 166 | ): 167 | # parts looks like -> ('top_module', 'sub', 'sub_sub') 168 | # docs_path looks like -> api_root_uri/top_module/sub/sub_sub/index.md 169 | # and refers to the location in the BUILT site directory 170 | 171 | # Check exclusion conditions 172 | if exclude_private and any(part.startswith("_") for part in name_parts): 173 | continue 174 | 175 | # Check direct path exclusions 176 | mod_path = ".".join(name_parts) 177 | if any(mod_path == x or mod_path.startswith(x) for x in exclude_paths): 178 | logger.info("Excluding %r due to config.exclude", mod_path) 179 | continue 180 | 181 | # Check regex exclusions 182 | if any(pattern.search(mod_path) for pattern in exclude_patterns): 183 | logger.info("Excluding %r due to config.exclude", mod_path) 184 | continue 185 | 186 | # create the actual markdown that will go into the virtual file 187 | content = self._module_markdown(name_parts) 188 | 189 | # generate a mkdocs File object and add it to the collection 190 | logger.info("Documenting %r in virtual file: %s", mod_path, docs_path) 191 | file = File.generated(config, src_uri=docs_path, content=content) 192 | if file.src_uri in files.src_uris: # pragma: no cover 193 | files.remove(file) 194 | files.append(file) 195 | if self._uses_awesome_nav and docs_path.endswith("index.md"): 196 | # https://lukasgeiter.github.io/mkdocs-awesome-nav/features/titles/ 197 | nav_path = docs_path.replace("index.md", ".nav.yml") 198 | content = f"title: {self._display_title(name_parts)}\n" 199 | nav_yml = File.generated(config, src_uri=nav_path, content=content) 200 | files.append(nav_yml) 201 | 202 | # update our navigation tree 203 | self.nav.add_path(name_parts, docs_path, file=file) 204 | 205 | # TODO: it's probably better to do this in the on_nav method 206 | # Render the navigation tree to dict and add to config['nav'] 207 | if cfg_nav := config.nav: 208 | _merge_nav( 209 | cfg_nav, 210 | self.config.nav_section_title, 211 | self.nav.as_dict(), 212 | self.config.api_root_uri, 213 | ) 214 | # note, if there is NO existing nav, then mkdocs will 215 | # find the pages and include them in the nav automatically 216 | 217 | def on_nav( 218 | self, nav: Navigation, /, *, config: MkDocsConfig, files: Files 219 | ) -> Navigation: 220 | """Called after the navigation is created, but before it is rendered.""" 221 | if not config.nav and not self._uses_awesome_nav: 222 | # there was no nav specified in the config. 223 | # let's correct the autogenerated navigation, which will be lacking 224 | # titles, have the wrong name, and be missing prefixes. 225 | for item in nav.items: 226 | if isinstance(item, Section) and item.title == "Reference": 227 | # this is the section we created in on_files 228 | # let's fix it up 229 | self._fix_nav_item(item) 230 | item.title = self.config.nav_section_title 231 | break 232 | return nav 233 | 234 | def _display_title(self, parts: Sequence[str]) -> str: 235 | if self.config.show_full_namespace: 236 | return ".".join(parts) 237 | return parts[-1] 238 | 239 | def _fix_nav_item(self, item: StructureItem) -> None: 240 | """Recursively fix titles of members in section. 241 | 242 | Section(title='Reference') 243 | Section(title='Module') 244 | Page(title=[blank], url='/reference/module/') 245 | 246 | Section(title='API Reference') 247 | Section(title='{prefix}module') 248 | Page(title='{prefix}module', url='/reference/module/') 249 | """ 250 | if isinstance(item, Section): 251 | parts = [] 252 | # make sure that Section titles *also* obey the full namespace rules 253 | if self.config.show_full_namespace: 254 | parts = [ 255 | x.title.split(">")[-1] 256 | for x in list(item.ancestors)[:-1] 257 | if isinstance(x, Section) 258 | ] 259 | parts.append(item.title.lower()) 260 | title = ".".join(parts).replace(" ", "_") 261 | item.title = f"{self.config.nav_item_prefix}{title}" 262 | for child in item.children: 263 | self._fix_nav_item(child) 264 | elif isinstance(item, Page): 265 | if not item.title: 266 | parts = item.url.split("/") 267 | item.meta["title"] = f"{self.config.nav_item_prefix}{parts[-2]}" 268 | 269 | 270 | # ----------------------------------------------------------------------------- 271 | 272 | 273 | def _iter_modules( 274 | root_module: Path | str, 275 | docs_root: str, 276 | on_implicit_namespace_package: WarnRaiseSkip, 277 | ) -> Iterator[tuple[tuple[str, ...], str]]: 278 | """Recursively collect all modules starting at `module_path`. 279 | 280 | Yields a tuple of parts (e.g. ('top_module', 'sub', 'sub_sub')) and the 281 | path where the corresponding documentation file should be written. 282 | """ 283 | root_module = Path(root_module) 284 | for abs_path in sorted(_iter_py_files(root_module, on_implicit_namespace_package)): 285 | rel_path = abs_path.relative_to(root_module.parent) 286 | doc_path = rel_path.with_suffix(".md") 287 | full_doc_path = Path(docs_root, doc_path) 288 | parts = tuple(rel_path.with_suffix("").parts) 289 | 290 | if parts[-1] == "__init__": 291 | parts = parts[:-1] 292 | doc_path = doc_path.with_name("index.md") 293 | full_doc_path = full_doc_path.with_name("index.md") 294 | if parts[-1] == "index": 295 | # deal with the special case of a module named 'index.py' 296 | # we don't want to name it index.md, since that is a special 297 | # name for a directory index 298 | full_doc_path = full_doc_path.with_name("index_py.md") 299 | 300 | yield parts, str(full_doc_path) 301 | 302 | 303 | def _iter_py_files( 304 | root_module: str | Path, on_implicit_namespace_package: WarnRaiseSkip 305 | ) -> Iterator[Path]: 306 | """Recursively collect all modules starting at `root_module`. 307 | 308 | Recursively walks from a given root folder, yielding .py files. Allows special 309 | handling of implicit namespace packages. 310 | """ 311 | root_path = Path(root_module) 312 | 313 | # Skip this directory entirely if it isn't an explicit package. 314 | if _is_implicit_namespace_package(root_path): 315 | if on_implicit_namespace_package == "raise": 316 | raise RuntimeError( 317 | f"Implicit namespace package (without an __init__.py file) detected at " 318 | f"{root_path}.\nThis will likely cause a collection error in " 319 | "mkdocstrings. Set 'on_implicit_namespace_package' to 'skip' to omit " 320 | "this package from the documentation, or 'warn' to include it anyway " 321 | "but log a warning." 322 | ) 323 | else: 324 | if on_implicit_namespace_package == "skip": 325 | logger.info( 326 | "Skipping implicit namespace package (without an __init__.py file) " 327 | "at %s", 328 | root_path, 329 | ) 330 | else: # on_implicit_namespace_package == "warn": 331 | logger.warning( 332 | "Skipping implicit namespace package (without an __init__.py file) " 333 | "at %s. Set 'on_implicit_namespace_package' to 'skip' to omit it " 334 | "without warning.", 335 | root_path, 336 | ) 337 | return 338 | 339 | # Yield .py files in the current directory. 340 | for item in root_path.iterdir(): 341 | if item.is_file() and item.suffix == ".py": 342 | yield item 343 | elif item.is_dir(): 344 | yield from _iter_py_files(item, on_implicit_namespace_package) 345 | 346 | 347 | def _is_implicit_namespace_package(path: Path) -> bool: 348 | """Return True if the given path is an implicit namespace package. 349 | 350 | An implicit namespace package is a directory that does not contain an 351 | __init__.py file, but *does* have python files in it. 352 | """ 353 | return not (path / "__init__.py").is_file() and any(path.glob("*.py")) 354 | 355 | 356 | def _merge_nav( 357 | cfg_nav: list, nav_section_title: str, nav_dict: dict, root: str 358 | ) -> None: 359 | """Mutate cfg_nav list in place, to add in our own nav_dict. 360 | 361 | Parameters 362 | ---------- 363 | cfg_nav : list 364 | The current navigation structure. A list as defined in mkdocs.yaml by the user. 365 | nav_section_title : str 366 | The title of the section we are adding to the navigation (e.g. "API Reference" 367 | if the user has not changed it). 368 | nav_dict : dict 369 | The autogenerated navigation structure for the API reference section, likely 370 | constructed during our plugin's `on_files` method. 371 | root : str 372 | The root API uri for the API docs. (e.g. "reference" if the user has 373 | not changed it). 374 | """ 375 | # look for existing nav items that match the API header 376 | for position, item in enumerate(list(cfg_nav)): 377 | if isinstance(item, str) and item == nav_section_title: 378 | # someone simply placed the string ref... replace with full nav dict 379 | cfg_nav[position] = {nav_section_title: nav_dict} 380 | return 381 | 382 | if isinstance(item, dict): 383 | name, value = next(iter(item.items())) 384 | if name != nav_section_title: 385 | continue # pragma: no cover 386 | 387 | # if we get here, it means that the API section already exists 388 | # we need to merge in our navigation info 389 | if isinstance(value, str): 390 | # The section exists and it is a single string, perhaps 391 | # associated with literate-nav-type items to come later? 392 | # let's put our stuff here, but assert the name of the directory 393 | # matches where we expect to put the API docs. 394 | if value not in {root.rstrip("/"), root.rstrip("/") + "/"}: 395 | # unexpected... 396 | logger.error( 397 | "Encountered pre-existing navigation section %r " 398 | "with unexpected value %r (expected %r). " 399 | "Skipping...", 400 | name, 401 | value, 402 | root, 403 | ) 404 | return 405 | 406 | # replace the string with our full nav dict 407 | cfg_nav[position] = {nav_section_title: nav_dict} 408 | return 409 | 410 | if isinstance(value, list): 411 | # The section exists and it is already a list of items 412 | # append our new nav to the list 413 | # breaking up nav_dict into a list of dicts, with a single key 414 | # and value each 415 | if len(nav_dict) == 1: 416 | value.append(nav_dict) 417 | else: 418 | value.extend([{k: v} for k, v in nav_dict.items()]) 419 | return 420 | 421 | # we've reached the end of the list without finding the API section 422 | # add it to the end 423 | cfg_nav.append({nav_section_title: nav_dict}) 424 | 425 | 426 | @dataclass 427 | class _NavNode: 428 | """Simple helper node used while building the navigation tree.""" 429 | 430 | doc_path: str | None = None 431 | children: dict[str, _NavNode] = field(default_factory=dict) 432 | name_prefix: str = "" 433 | file: File | None = None 434 | title: str = "" 435 | show_full_namespace: bool = False 436 | 437 | def as_dict(self) -> dict: 438 | return { 439 | f"{self.name_prefix}{name}": node.as_obj() 440 | for name, node in self.children.items() 441 | } 442 | 443 | def add_path(self, parts: tuple[str, ...], doc_path: str, file: File) -> None: 444 | """Add a path to the tree.""" 445 | node = self 446 | for part in parts: 447 | if part not in node.children: 448 | title = ".".join(parts) if self.show_full_namespace else part 449 | node.children[part] = _NavNode( 450 | name_prefix=node.name_prefix, 451 | file=file, 452 | title=title, 453 | show_full_namespace=node.show_full_namespace, 454 | ) 455 | node = node.children[part] 456 | node.doc_path = doc_path 457 | 458 | def as_obj(self) -> list | str: 459 | """Convert the tree to nested list/dict form.""" 460 | if not self.children: 461 | return self.doc_path or "" 462 | 463 | # Has children -> return a list 464 | result: list[str | dict[str, str | list]] = [] 465 | if self.doc_path: 466 | # Put the doc_path first, if it exists 467 | result.append(self.doc_path) 468 | 469 | # Then each child is {child_name: child_structure} 470 | # (in insertion order or sorted—here we just rely on insertion order) 471 | for child_node in self.children.values(): 472 | result.append({child_node.full_title: child_node.as_obj()}) 473 | return result 474 | 475 | @property 476 | def full_title(self) -> str: 477 | """Return the full title of the node.""" 478 | return self.name_prefix + self.title 479 | --------------------------------------------------------------------------------