1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
|
"""Tests for our own API exposition."""
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING
import griffe
import pytest
from mkdocstrings import Inventory
from mkdocstrings_handlers import python
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture(name="loader", scope="module")
def _fixture_loader() -> griffe.GriffeLoader:
loader = griffe.GriffeLoader()
loader.load("mkdocstrings")
loader.load("mkdocstrings_handlers.python")
loader.resolve_aliases()
return loader
@pytest.fixture(name="internal_api", scope="module")
def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module:
return loader.modules_collection["mkdocstrings_handlers.python._internal"]
@pytest.fixture(name="public_api", scope="module")
def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module:
return loader.modules_collection["mkdocstrings_handlers.python"]
def _yield_public_objects(
obj: griffe.Module | griffe.Class,
*,
modules: bool = False,
modulelevel: bool = True,
inherited: bool = False,
special: bool = False,
) -> Iterator[griffe.Object | griffe.Alias]:
for member in obj.all_members.values() if inherited else obj.members.values():
try:
if member.is_module:
if member.is_alias or not member.is_public:
continue
if modules:
yield member
yield from _yield_public_objects(
member, # type: ignore[arg-type]
modules=modules,
modulelevel=modulelevel,
inherited=inherited,
special=special,
)
elif member.is_public and (special or not member.is_special):
yield member
else:
continue
if member.is_class and not modulelevel:
yield from _yield_public_objects(
member, # type: ignore[arg-type]
modules=modules,
modulelevel=False,
inherited=inherited,
special=special,
)
except (griffe.AliasResolutionError, griffe.CyclicAliasError):
continue
@pytest.fixture(name="modulelevel_internal_objects", scope="module")
def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
return list(_yield_public_objects(internal_api, modulelevel=True))
@pytest.fixture(name="internal_objects", scope="module")
def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
return list(_yield_public_objects(internal_api, modulelevel=False, special=True))
@pytest.fixture(name="public_objects", scope="module")
def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True))
@pytest.fixture(name="inventory", scope="module")
def _fixture_inventory() -> Inventory:
inventory_file = Path(__file__).parent.parent / "site" / "objects.inv"
if not inventory_file.exists():
raise pytest.skip("The objects inventory is not available.")
with inventory_file.open("rb") as file:
return Inventory.parse_sphinx(file)
def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None:
"""All public objects in the internal API are exposed under `mkdocstrings_handlers.python`."""
not_exposed = [
obj.path
for obj in modulelevel_internal_objects
if obj.name not in python.__all__ or not hasattr(python, obj.name)
]
assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed))
def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None:
"""All internal objects have unique names."""
names_to_paths = defaultdict(list)
for obj in modulelevel_internal_objects:
names_to_paths[obj.name].append(obj.path)
non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1]
assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique)
def test_single_locations(public_api: griffe.Module) -> None:
"""All objects have a single public location."""
def _public_path(obj: griffe.Object | griffe.Alias) -> bool:
return obj.is_public and (obj.parent is None or _public_path(obj.parent))
multiple_locations = {}
for obj_name in python.__all__:
obj = public_api[obj_name]
if obj.aliases and (
public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)]
):
multiple_locations[obj.path] = public_aliases
assert not multiple_locations, "Multiple public locations:\n" + "\n".join(
f"{path}: {aliases}" for path, aliases in multiple_locations.items()
)
def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None:
"""All public objects are added to the inventory."""
ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"}
not_in_inventory = [
obj.path for obj in public_objects if obj.name not in ignore_names and obj.path not in inventory
]
msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}"
assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory)))
def _module_or_child(parent: str, name: str) -> bool:
parents = [parent[: i + 1] for i, char in enumerate(parent) if char == "."]
parents.append(parent)
return name in parents or name.startswith(parent + ".")
def test_inventory_matches_api(
inventory: Inventory,
public_objects: list[griffe.Object | griffe.Alias],
loader: griffe.GriffeLoader,
) -> None:
"""The inventory doesn't contain any additional Python object."""
not_in_api = []
public_api_paths = {obj.path for obj in public_objects}
public_api_paths.add("mkdocstrings_handlers")
public_api_paths.add("mkdocstrings_handlers.python")
# YORE: Bump 2: Remove block.
public_api_paths.add("mkdocstrings_handlers.python.config")
public_api_paths.add("mkdocstrings_handlers.python.handler")
public_api_paths.add("mkdocstrings_handlers.python.rendering")
for item in inventory.values():
if item.domain == "py" and "(" not in item.name and _module_or_child("mkdocstrings_handlers.python", item.name):
obj = loader.modules_collection[item.name]
if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases):
not_in_api.append(item.name)
msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}"
assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api)))
def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None:
"""No module docstrings should be written in our internal API.
The reasoning is that docstrings are addressed to users of the public API,
but internal modules are not exposed to users, so they should not have docstrings.
"""
def _modules(obj: griffe.Module) -> Iterator[griffe.Module]:
for member in obj.modules.values():
yield member
yield from _modules(member)
for obj in _modules(internal_api):
assert not obj.docstring
|