├── 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 | [](https://github.com/tlambert03/mkdocs-api-autonav/raw/main/LICENSE)
4 | [](https://pypi.org/project/mkdocs-api-autonav)
5 | [](https://python.org)
6 | [](https://github.com/tlambert03/mkdocs-api-autonav/actions/workflows/ci.yml)
7 | [](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 | |
|
|
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 |
--------------------------------------------------------------------------------