Coverage for tests/test_api.py: 91.94%
94 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-24 14:21 +0100
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-24 14:21 +0100
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.inventory import Inventory
13import mkdocs_autorefs
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("mkdocs_autorefs")
23 loader.resolve_aliases()
24 return loader
27@pytest.fixture(name="internal_api", scope="module")
28def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module:
29 return loader.modules_collection["mkdocs_autorefs._internal"]
32@pytest.fixture(name="public_api", scope="module")
33def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module:
34 return loader.modules_collection["mkdocs_autorefs"]
37def _yield_public_objects(
38 obj: griffe.Module | griffe.Class,
39 *,
40 modules: bool = False,
41 modulelevel: bool = True,
42 inherited: bool = False,
43 special: bool = False,
44) -> Iterator[griffe.Object | griffe.Alias]:
45 for member in obj.all_members.values() if inherited else obj.members.values():
46 try:
47 if member.is_module:
48 if member.is_alias or not member.is_public:
49 continue
50 if modules: 50 ↛ 51line 50 didn't jump to line 51 because the condition on line 50 was never true
51 yield member
52 yield from _yield_public_objects(
53 member, # type: ignore[arg-type]
54 modules=modules,
55 modulelevel=modulelevel,
56 inherited=inherited,
57 special=special,
58 )
59 elif member.is_public and (special or not member.is_special):
60 yield member
61 else:
62 continue
63 if member.is_class and not modulelevel:
64 yield from _yield_public_objects(
65 member, # type: ignore[arg-type]
66 modules=modules,
67 modulelevel=False,
68 inherited=inherited,
69 special=special,
70 )
71 except (griffe.AliasResolutionError, griffe.CyclicAliasError):
72 continue
75@pytest.fixture(name="modulelevel_internal_objects", scope="module")
76def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
77 return list(_yield_public_objects(internal_api, modulelevel=True))
80@pytest.fixture(name="internal_objects", scope="module")
81def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
82 return list(_yield_public_objects(internal_api, modulelevel=False, special=True))
85@pytest.fixture(name="public_objects", scope="module")
86def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
87 return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True))
90@pytest.fixture(name="inventory", scope="module")
91def _fixture_inventory() -> Inventory:
92 inventory_file = Path(__file__).parent.parent / "site" / "objects.inv"
93 if not inventory_file.exists(): 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 raise pytest.skip("The objects inventory is not available.")
95 with inventory_file.open("rb") as file:
96 return Inventory.parse_sphinx(file)
99def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None:
100 """All public objects in the internal API are exposed under `mkdocs_autorefs`."""
101 not_exposed = [
102 obj.path
103 for obj in modulelevel_internal_objects
104 if obj.name not in mkdocs_autorefs.__all__ or not hasattr(mkdocs_autorefs, obj.name)
105 ]
106 assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed))
109def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None:
110 """All internal objects have unique names."""
111 names_to_paths = defaultdict(list)
112 for obj in modulelevel_internal_objects:
113 names_to_paths[obj.name].append(obj.path)
114 non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1]
115 assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique)
118def test_single_locations(public_api: griffe.Module) -> None:
119 """All objects have a single public location."""
121 def _public_path(obj: griffe.Object | griffe.Alias) -> bool:
122 return obj.is_public and (obj.parent is None or _public_path(obj.parent))
124 multiple_locations = {}
125 for obj_name in mkdocs_autorefs.__all__:
126 obj = public_api[obj_name]
127 if obj.aliases and ( 127 ↛ 130line 127 didn't jump to line 130 because the condition on line 127 was never true
128 public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)]
129 ):
130 multiple_locations[obj.path] = public_aliases
131 assert not multiple_locations, "Multiple public locations:\n" + "\n".join(
132 f"{path}: {aliases}" for path, aliases in multiple_locations.items()
133 )
136def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None:
137 """All public objects are added to the inventory."""
138 ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"}
139 not_in_inventory = [
140 obj.path for obj in public_objects if obj.name not in ignore_names and obj.path not in inventory
141 ]
142 msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}"
143 assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory)))
146def test_inventory_matches_api(
147 inventory: Inventory,
148 public_objects: list[griffe.Object | griffe.Alias],
149 loader: griffe.GriffeLoader,
150) -> None:
151 """The inventory doesn't contain any additional Python object."""
152 not_in_api = []
153 public_api_paths = {obj.path for obj in public_objects}
154 public_api_paths.add("mkdocs_autorefs")
155 ignore = {"mkdocs_autorefs.plugin", "mkdocs_autorefs.references"}
156 for item in inventory.values():
157 if item.domain == "py" and "(" not in item.name: 157 ↛ 156line 157 didn't jump to line 156 because the condition on line 157 was always true
158 obj = loader.modules_collection[item.name]
159 if ( 159 ↛ 164line 159 didn't jump to line 164 because the condition on line 159 was never true
160 obj.path not in ignore
161 and obj.path not in public_api_paths
162 and not any(path in public_api_paths for path in obj.aliases)
163 ):
164 not_in_api.append(item.name)
165 msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}"
166 assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api)))
169def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None:
170 """No module docstrings should be written in our internal API.
172 The reasoning is that docstrings are addressed to users of the public API,
173 but internal modules are not exposed to users, so they should not have docstrings.
174 """
176 def _modules(obj: griffe.Module) -> Iterator[griffe.Module]:
177 for member in obj.modules.values():
178 yield member
179 yield from _modules(member)
181 for obj in _modules(internal_api):
182 assert not obj.docstring, f"Object {obj.path} has a docstring."