Files
zensical/python/tests/integration/test_api_generators.py
T
2026-10-05 17:01:09 +02:00

680 lines
23 KiB
Python
Vendored

# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import json
import subprocess
import sys
import time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
import pytest
import yaml
from bs4 import BeautifulSoup
import zensical
def project(
root: Path,
plugin: str,
options: dict,
nav: list | None = None,
extra_plugins: list | None = None,
) -> Path:
docs = root / "docs"
docs.mkdir(exist_ok=True)
(docs / "index.md").write_text("# Home\n")
package = root / "src" / "sample"
package.mkdir(parents=True, exist_ok=True)
for name, content in {
"__init__.py": '"""Sample package."""\n',
"public.py": '"""Public module documentation."""\n',
"_private.py": '"""Private module documentation."""\n',
"index.py": '"""Index module documentation."""\n',
}.items():
(package / name).write_text(content)
config = {
"site_name": "API test",
"theme": {"features": ["content.action.edit"]},
"repo_url": "https://example.com/repository",
"edit_uri": "edit/main/docs",
"plugins": [
{plugin: options},
{"mkdocstrings": {"handlers": {"python": {"paths": ["src"]}}}},
*(extra_plugins or []),
],
}
if nav is not None:
config["nav"] = nav
path = root / "mkdocs.yml"
path.write_text(yaml.safe_dump(config, sort_keys=False))
return path
def build(path: Path, *, strict: bool = True) -> None:
zensical.build(str(path), {"clean": False, "strict": strict})
def navigation_template(config: Path) -> None:
"""Render navigation entries with their depth, title, and URL."""
overrides = config.parent / "overrides"
overrides.mkdir()
(overrides / "main.html").write_text(
"""\
{% macro render(items, depth) %}
{% for item in items %}
<item depth="{{ depth }}" title="{{ (item.title or '') | e }}"
url="{{ (item.url or '') | e }}" />
{{ render(item.children, depth + 1) }}
{% endfor %}
{% endmacro %}
{{ render(nav.items, 0) }}
"""
)
data = yaml.safe_load(config.read_text())
data["theme"]["custom_dir"] = "overrides"
config.write_text(yaml.safe_dump(data, sort_keys=False))
def navigation_items(root: Path) -> list[tuple[int, str, str]]:
soup = BeautifulSoup((root / "site/index.html").read_text(), "html.parser")
return [
(int(str(item["depth"])), str(item["title"]), str(item["url"]))
for item in soup.find_all("item")
]
@pytest.mark.parametrize(
("plugin", "options", "prefix"),
[
("mkdocs-autoapi", {"autoapi_dir": "src"}, "autoapi"),
("api-autonav", {"modules": ["src/sample"]}, "reference"),
],
)
def test_generates_api_pages_and_navigation(
tmp_path: Path, plugin: str, options: dict, prefix: str
) -> None:
config = project(tmp_path, plugin, options)
if plugin == "mkdocs-autoapi":
# Upstream AutoAPI uses index.md for both index.py and __init__.py.
(tmp_path / "src/sample/index.py").unlink()
build(config)
home = (tmp_path / "site/index.html").read_text()
page = (tmp_path / f"site/{prefix}/sample/public/index.html").read_text()
assert "Public module documentation" in page
assert "API Reference" in home
assert f"{prefix}/sample/public/" in home
assert not (tmp_path / f"docs/{prefix}").exists()
assert (
tmp_path / f"site/{prefix}/sample/_private/index.html"
).exists() == (plugin == "mkdocs-autoapi")
search = json.loads((tmp_path / "site/search.json").read_text())
assert any(
"Public module documentation" in doc["text"] for doc in search["items"]
)
@pytest.mark.parametrize(
("plugin", "options", "prefix"),
[
("mkdocs-autoapi", {"autoapi_dir": "src"}, "autoapi"),
("api-autonav", {"modules": ["src/sample"]}, "reference"),
],
)
def test_exclude_filters_generated_api_pages(
tmp_path: Path, plugin: str, options: dict, prefix: str
) -> None:
config = project(
tmp_path,
plugin,
options,
extra_plugins=[{"exclude": {"glob": f"{prefix}/sample/public.md"}}],
)
build(config, strict=False)
# Generated Markdown follows the same exclusion rules as physical pages.
assert not (tmp_path / f"site/{prefix}/sample/public/index.html").exists()
assert (tmp_path / f"site/{prefix}/sample/index.html").exists()
search = (tmp_path / "site/search.json").read_text()
assert "Public module documentation" not in search
def test_autoapi_patterns_stubs_keep_and_manual_navigation(
tmp_path: Path,
) -> None:
config = project(
tmp_path,
"mkdocs-autoapi",
{
"autoapi_dir": "src/sample",
"autoapi_file_patterns": ["*.pyi", "*.py"],
"autoapi_ignore": ["index.py", "_private.py"],
"autoapi_root": "api",
"autoapi_add_nav_entry": False,
"autoapi_keep_files": True,
},
nav=[{"Home": "index.md"}, {"Manual API": "api/"}],
)
(tmp_path / "src/sample/public.pyi").write_text('"""Stub docs."""\n')
build(config)
assert (
tmp_path / "docs/api/sample/public.md"
).read_text() == "::: sample.public\n"
summary = (tmp_path / "docs/api/summary.md").read_text()
assert "[public](sample/public.md)" in summary
assert "_private" not in summary
assert not (tmp_path / "site/api/summary/index.html").exists()
home = (tmp_path / "site/index.html").read_text()
assert "Manual API" in home
assert "API Reference" not in home
@pytest.mark.parametrize("preferred", ["py", "pyi"])
@pytest.mark.parametrize("source_root", ["src", "src/sample"])
def test_autoapi_excludes_preferred_source_without_falling_back(
tmp_path: Path, preferred: str, source_root: str
) -> None:
fallback = "pyi" if preferred == "py" else "py"
ignored = (
f"**/public.{preferred}"
if source_root == "src"
else f"public.{preferred}"
)
config = project(
tmp_path,
"mkdocs-autoapi",
{
"autoapi_dir": source_root,
"autoapi_file_patterns": [f"*.{preferred}", f"*.{fallback}"],
"autoapi_ignore": [ignored, "**/index.py"],
"autoapi_keep_files": True,
},
)
(tmp_path / "src/sample/public.pyi").write_text(
'"""Stub documentation."""\n'
)
build(config)
# Excluding the selected extension must exclude the module altogether.
assert (tmp_path / "docs/autoapi/sample/index.md").exists()
assert not (tmp_path / "docs/autoapi/sample/public.md").exists()
assert not (tmp_path / "site/autoapi/sample/public/index.html").exists()
assert "public" not in (tmp_path / "docs/autoapi/summary.md").read_text()
@pytest.mark.parametrize("api_root", ["reference", "api/reference"])
def test_autonav_preserves_handwritten_pages_in_automatic_navigation(
tmp_path: Path, api_root: str
) -> None:
config = project(
tmp_path,
"api-autonav",
{
"modules": ["src/sample"],
"api_root_uri": api_root,
"nav_item_prefix": "MOD ",
"show_full_namespace": True,
},
)
directory = tmp_path / "docs" / api_root
(directory / "sample/topics").mkdir(parents=True)
(directory / "overview.md").write_text("# API overview\n")
(directory / "sample/guide.md").write_text("# Package guide\n")
(directory / "sample/topics/start.md").write_text("# Getting started\n")
navigation_template(config)
build(config)
# Automatic navigation includes every page, with API section titles applied.
assert navigation_items(tmp_path) == [
(0, "Home", ""),
(0, "API Reference", ""),
(1, "API overview", f"{api_root}/overview/"),
(1, "MOD sample", ""),
(2, "sample", f"{api_root}/sample/"),
(2, "Package guide", f"{api_root}/sample/guide/"),
(2, "sample.index", f"{api_root}/sample/index_py/"),
(2, "sample.public", f"{api_root}/sample/public/"),
(2, "MOD sample.topics", ""),
(3, "Getting started", f"{api_root}/sample/topics/start/"),
]
def test_autonav_preserves_directory_links_with_a_different_title(
tmp_path: Path,
) -> None:
config = project(
tmp_path,
"api-autonav",
{"modules": ["src/sample"], "nav_item_prefix": ""},
nav=[{"Home": "index.md"}, {"Manual docs": "reference/"}],
)
navigation_template(config)
build(config)
# Only the configured API section title is an Autonav placeholder.
items = navigation_items(tmp_path)
assert items[:3] == [
(0, "Home", ""),
(0, "Manual docs", "reference/"),
(0, "API Reference", ""),
]
assert (2, "public", "reference/sample/public/") in items
@pytest.mark.parametrize(
"nav",
[
["API Reference", {"Home": "index.md"}],
[{"API Reference": "api/"}, {"Home": "index.md"}],
[{"API Reference": [{"Intro": "intro.md"}]}, {"Home": "index.md"}],
[{"Nested": [{"API Reference": "api"}]}, {"Home": "index.md"}],
],
)
def test_autonav_navigation_and_module_options(
tmp_path: Path, nav: list
) -> None:
config = project(
tmp_path,
"api-autonav",
{
"modules": ["src/sample"],
"api_root_uri": "api",
"nav_item_prefix": "MOD ",
"show_full_namespace": True,
"exclude_private": False,
"exclude": ["sample.index", r"re:sample\._private$"],
"module_options": {
".*": {"heading_level": 1},
r"sample\.public$": {
"heading_level": 2,
"show_root_heading": True,
},
},
},
nav=nav,
)
(tmp_path / "docs/intro.md").write_text("# Introduction\n")
build(config)
page = (tmp_path / "site/api/sample/public/index.html").read_text()
assert "MOD sample.public" in page
assert '<h1 id="samplepublic"' in page
assert not (tmp_path / "site/api/sample/index_py").exists()
assert not (tmp_path / "site/api/sample/_private").exists()
@pytest.mark.parametrize("policy", ["raise", "warn", "skip"])
def test_namespace_policy(tmp_path: Path, policy: str) -> None:
config = project(
tmp_path,
"api-autonav",
{
"modules": ["src/sample"],
"on_implicit_namespace_package": policy,
},
)
namespace = tmp_path / "src/sample/implicit"
namespace.mkdir()
(namespace / "child.py").write_text('"""Child."""\n')
if policy == "raise":
with pytest.raises(RuntimeError, match="implicit namespace package"):
build(config)
else:
build(config, strict=False)
assert not (tmp_path / "site/reference/sample/implicit").exists()
assert (tmp_path / "site/reference/sample/public/index.html").exists()
@pytest.mark.parametrize("full_namespace", [False, True])
def test_autonav_with_awesome_nav(tmp_path: Path, full_namespace: bool) -> None:
config = project(
tmp_path,
"api-autonav",
{
"modules": ["src/sample"],
"show_full_namespace": full_namespace,
"nav_item_prefix": "MOD ",
},
extra_plugins=["awesome-nav"],
)
package = tmp_path / "src/sample/sub_package"
package.mkdir()
(package / "__init__.py").write_text('"""Subpackage documentation."""\n')
(package / "child.py").write_text('"""Child module documentation."""\n')
navigation_template(config)
build(config)
# Awesome-nav uses its own root title and the generated package titles.
items = navigation_items(tmp_path)
assert items[:3] == [
(0, "Home", ""),
(0, "Reference", ""),
(1, "sample", ""),
]
assert (
2,
"sample.public" if full_namespace else "public",
"reference/sample/public/",
) in items
assert (
2,
"sample.sub_package" if full_namespace else "sub_package",
"",
) in items
assert not (tmp_path / "docs/reference").exists()
assert not list((tmp_path / "site").rglob(".nav.yml"))
@pytest.mark.parametrize("plugin", ["mkdocs-autoapi", "api-autonav"])
@pytest.mark.parametrize("control", ["hide: true\n", "nav:\n - overview.md\n"])
def test_awesome_nav_controls_generated_api_pages(
tmp_path: Path, plugin: str, control: str
) -> None:
options = (
{
"autoapi_dir": "src",
"autoapi_root": "reference",
"autoapi_ignore": ["**/index.py"],
}
if plugin == "mkdocs-autoapi"
else {"modules": ["src/sample"]}
)
config = project(tmp_path, plugin, options, extra_plugins=["awesome-nav"])
directory = tmp_path / "docs/reference"
directory.mkdir()
(directory / ".nav.yml").write_text(control)
(directory / "overview.md").write_text("# API overview\n")
navigation_template(config)
build(config)
# API pages still build, but awesome-nav controls their navigation entries.
assert (tmp_path / "site/reference/sample/public/index.html").exists()
expected = [(0, "Home", "")]
if control.startswith("nav:"):
expected.extend(
[
(0, "Reference", ""),
(1, "API overview", "reference/overview/"),
]
)
assert navigation_items(tmp_path) == expected
def test_autonav_index_module_and_source_rebuild(tmp_path: Path) -> None:
config = project(tmp_path, "api-autonav", {"modules": ["src/sample"]})
build(config)
assert (tmp_path / "site/reference/sample/index_py/index.html").exists()
(tmp_path / "src/sample/public.py").write_text(
'"""Updated module documentation."""\n'
)
build(config)
page = (tmp_path / "site/reference/sample/public/index.html").read_text()
assert "Updated module documentation" in page
(tmp_path / "src/sample/public.py").unlink()
build(config)
assert not (tmp_path / "site/reference/sample/public/index.html").exists()
@pytest.mark.parametrize("plugin", ["mkdocs-autoapi", "api-autonav"])
def test_serve_discovers_added_renamed_and_removed_modules(
tmp_path: Path, plugin: str
) -> None:
options = (
{"autoapi_dir": "src", "autoapi_ignore": ["**/index.py"]}
if plugin == "mkdocs-autoapi"
else {"modules": ["src/sample"]}
)
config = project(tmp_path, plugin, options)
with config.open("a") as file:
file.write("dev_addr: 127.0.0.1:0\n")
prefix = "autoapi" if plugin == "mkdocs-autoapi" else "reference"
output = tmp_path / f"site/{prefix}/sample/public/index.html"
added = tmp_path / "src/sample/added.py"
added_output = tmp_path / f"site/{prefix}/sample/added/index.html"
renamed = tmp_path / "src/sample/renamed.py"
renamed_output = tmp_path / f"site/{prefix}/sample/renamed/index.html"
with (tmp_path / "serve.log").open("w+") as log:
process = subprocess.Popen( # noqa: S603
[
sys.executable,
"-m",
"zensical",
"serve",
"--config-file",
str(config),
],
cwd=tmp_path,
stdout=log,
stderr=subprocess.STDOUT,
)
def wait_for(condition: Callable[[], bool]) -> None:
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
try:
if condition():
return
except FileNotFoundError:
# A rebuild can remove output between checking and reading.
pass
if process.poll() is not None:
break
time.sleep(0.02)
log.seek(0)
raise AssertionError(log.read())
try:
wait_for(output.is_file)
added.write_text('"""New module."""\n')
wait_for(added_output.is_file)
added.rename(renamed)
wait_for(
lambda: renamed_output.is_file() and not added_output.exists()
)
renamed.write_text('"""Changed module."""\n')
wait_for(
lambda: (
renamed_output.exists()
and "Changed module" in renamed_output.read_text()
)
)
renamed.unlink()
wait_for(lambda: output.is_file() and not renamed_output.exists())
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
def test_generated_pages_work_with_redirects_and_edit_links(
tmp_path: Path,
) -> None:
config = project(
tmp_path,
"mkdocs-autoapi",
{
"autoapi_dir": "src",
"autoapi_ignore": ["**/index.py"],
},
extra_plugins=[
{
"redirects": {
"redirect_maps": {
"old-api.md": "autoapi/sample/public.md",
"old-home.md": "index.md",
}
}
}
],
)
build(config)
page = (tmp_path / "site/autoapi/sample/public/index.html").read_text()
assert (
"https://example.com/repository/edit/main/src/sample/public.py" in page
)
assert (tmp_path / "site/old-api/index.html").exists()
assert (tmp_path / "site/old-home/index.html").exists()
def test_autoapi_can_link_retained_docs_without_generating(
tmp_path: Path,
) -> None:
config = project(
tmp_path,
"mkdocs-autoapi",
{
"autoapi_generate_api_docs": False,
"autoapi_add_nav_entry": "Saved API",
},
)
directory = tmp_path / "docs/autoapi"
directory.mkdir()
(directory / "topic.md").write_text("# Saved topic\n")
(directory / "summary.md").write_text("- [Saved topic](topic.md)\n")
build(config)
home = (tmp_path / "site/index.html").read_text()
assert "Saved API" in home
assert "autoapi/topic/" in home
assert not (tmp_path / "site/autoapi/sample").exists()
def test_autonav_single_file_private_modules_and_flat_urls(
tmp_path: Path,
) -> None:
config = project(
tmp_path,
"api-autonav",
{
"modules": ["src/sample"],
"exclude_private": False,
"nav_item_prefix": "",
},
)
with config.open("a") as file:
file.write("use_directory_urls: false\n")
build(config)
assert (tmp_path / "site/reference/sample/_private.html").exists()
page = (tmp_path / "site/reference/sample/public.html").read_text()
assert "repository/edit/main/docs/reference" not in page
data = yaml.safe_load(config.read_text())
data["plugins"][0]["api-autonav"]["modules"] = ["src/standalone.py"]
(tmp_path / "src/standalone.py").write_text('"""Standalone module."""\n')
config.write_text(yaml.safe_dump(data))
build(config)
assert (tmp_path / "site/reference/standalone.html").exists()
def test_autoapi_uses_vba_identifiers_for_custom_file_patterns(
tmp_path: Path,
) -> None:
config = project(
tmp_path,
"mkdocs-autoapi",
{
"autoapi_dir": "src",
"autoapi_file_patterns": ["*.bas"],
"autoapi_keep_files": True,
},
)
(tmp_path / "src/sample/code.bas").write_text("Sub Example()\nEnd Sub\n")
data = yaml.safe_load(config.read_text())
data["plugins"][1]["mkdocstrings"].update(
enabled=False, default_handler="vba"
)
config.write_text(yaml.safe_dump(data))
build(config)
assert (
tmp_path / "docs/autoapi/sample/code.md"
).read_text() == "::: sample/code.bas\n"
def test_autonav_preserves_root_order_and_uses_last_matching_options(
tmp_path: Path,
) -> None:
config = project(
tmp_path,
"api-autonav",
{
"modules": ["src/zeta", "src/alpha"],
"nav_item_prefix": "",
"module_options": {
"zeta": {"heading_level": 3},
".*": {"heading_level": 2, "show_root_heading": True},
},
},
nav=["API Reference"],
)
for name in ("zeta", "alpha"):
directory = tmp_path / "src" / name
directory.mkdir()
(directory / "__init__.py").write_text(f'"""{name} docs."""\n')
build(config)
home = (tmp_path / "site/index.html").read_text()
assert home.index('reference/zeta/" class="md-nav__link"') < home.index(
'reference/alpha/" class="md-nav__link"'
)
page = (tmp_path / "site/reference/zeta/index.html").read_text()
assert '<h2 id="zeta"' in page
def test_generators_replace_physical_collisions_and_inherit_metadata(
tmp_path: Path,
) -> None:
config = project(
tmp_path,
"api-autonav",
{"modules": ["src/sample"]},
extra_plugins=["meta"],
)
directory = tmp_path / "docs/reference/sample"
directory.mkdir(parents=True)
(directory / "public.md").write_text("# Physical collision\n")
(directory / ".meta.yml").write_text(
"description: Inherited API description\n"
)
build(config)
page = (tmp_path / "site/reference/sample/public/index.html").read_text()
assert "Public module documentation" in page
assert "Physical collision" not in page
assert "Inherited API description" in page
def test_disabled_api_generators_leave_docs_unchanged(tmp_path: Path) -> None:
config = project(tmp_path, "mkdocs-autoapi", {"enabled": False})
build(config)
assert not (tmp_path / "site/autoapi").exists()