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