Coverage for tests / test_api.py: 72.19%
123 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-11 11:48 +0100
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-11 11:48 +0100
1"""Tests for our own API exposition."""
3from __future__ import annotations
5from collections import defaultdict
6from fnmatch import fnmatch
7from pathlib import Path
8from typing import TYPE_CHECKING
10import pytest
11from mkdocstrings import Inventory
13import griffe
14import griffecli
16if TYPE_CHECKING:
17 from collections.abc import Iterable, Iterator
18 from types import ModuleType
21TESTED_MODULES = (griffe, griffecli)
22_test_all_modules = pytest.mark.parametrize("tested_module", TESTED_MODULES)
25@pytest.fixture(name="inventory", scope="module")
26def _fixture_inventory() -> Inventory:
27 inventory_file = Path(__file__).parent.parent / "site" / "objects.inv"
28 if not inventory_file.exists(): 28 ↛ 30line 28 didn't jump to line 30 because the condition on line 28 was always true
29 pytest.skip("The objects inventory is not available.")
30 with inventory_file.open("rb") as file:
31 return Inventory.parse_sphinx(file)
34def _load_modules(*modules: ModuleType) -> griffe.GriffeLoader:
35 loader = griffe.GriffeLoader(
36 extensions=griffe.load_extensions(
37 "griffe_inherited_docstrings",
38 "unpack_typeddict",
39 ),
40 )
41 for module in modules:
42 loader.load(module.__name__)
43 loader.resolve_aliases()
44 return loader
47def _get_internal_api(module: ModuleType, loader: griffe.GriffeLoader | None = None) -> griffe.Module:
48 if loader is None: 48 ↛ 50line 48 didn't jump to line 50 because the condition on line 48 was always true
49 loader = _load_modules(module)
50 return loader.modules_collection[module.__name__ + "._internal"]
53def _get_reexported_names(module: ModuleType) -> Iterable[str]:
54 return getattr(module, "_REEXPORTED_EXTERNAL_API", ())
57def _get_public_api(module: ModuleType, loader: griffe.GriffeLoader | None = None) -> griffe.Module:
58 if loader is None: 58 ↛ 60line 58 didn't jump to line 60 because the condition on line 58 was always true
59 loader = _load_modules(module)
60 return loader.modules_collection[module.__name__]
63def _yield_public_objects(
64 obj: griffe.Module | griffe.Class,
65 *,
66 modules: bool = False,
67 modulelevel: bool = True,
68 inherited: bool = False,
69 special: bool = False,
70) -> Iterator[griffe.Object | griffe.Alias]:
71 for member in obj.all_members.values() if inherited else obj.members.values():
72 try:
73 if member.is_module:
74 if member.is_alias:
75 continue
76 if member.is_public:
77 if modules: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 yield member
79 yield from _yield_public_objects(
80 member, # ty:ignore[invalid-argument-type]
81 modules=modules,
82 modulelevel=modulelevel,
83 inherited=inherited,
84 special=special,
85 )
86 elif member.is_public and (special or not member.is_special):
87 yield member
88 if member.is_class and not modulelevel: 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true
89 yield from _yield_public_objects(
90 member, # ty:ignore[invalid-argument-type]
91 modules=modules,
92 modulelevel=False,
93 inherited=inherited,
94 special=special,
95 )
96 except (griffe.AliasResolutionError, griffe.CyclicAliasError):
97 continue
100def _get_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
101 return list(_yield_public_objects(internal_api, modulelevel=True))
104def _get_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
105 return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True))
108def test_alias_proxies() -> None:
109 """The Alias class has all the necessary methods and properties."""
110 internal_api = _get_internal_api(griffe)
111 alias_members = set(internal_api["models.Alias"].all_members.keys())
112 for cls in (
113 internal_api["models.Module"],
114 internal_api["models.Class"],
115 internal_api["models.Function"],
116 internal_api["models.Attribute"],
117 ):
118 for name in cls.all_members:
119 if not name.startswith("_") or name.startswith("__"):
120 assert name in alias_members
123@_test_all_modules
124def test_exposed_objects(tested_module: ModuleType) -> None:
125 """All public objects in the internal API are exposed under `griffe`."""
126 modulelevel_internal_objects = _get_modulelevel_internal_objects(_get_internal_api(tested_module))
127 not_exposed = [
128 obj.path
129 for obj in modulelevel_internal_objects
130 if obj.name not in tested_module.__all__ or not hasattr(tested_module, obj.name)
131 ]
132 assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed))
135@_test_all_modules
136def test_unique_names(tested_module: ModuleType) -> None:
137 """All internal objects have unique names."""
138 modulelevel_internal_objects = _get_modulelevel_internal_objects(_get_public_api(tested_module))
139 names_to_paths = defaultdict(list)
140 for obj in modulelevel_internal_objects:
141 names_to_paths[obj.name].append(obj.path)
142 non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1]
143 assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique)
146@_test_all_modules
147def test_single_locations(tested_module: ModuleType) -> None:
148 """All objects have a single public location."""
150 def _public_path(obj: griffe.Object | griffe.Alias) -> bool:
151 return obj.is_public and (obj.parent is None or _public_path(obj.parent))
153 public_api = _get_public_api(tested_module)
154 multiple_locations = {}
155 for obj_name in set(tested_module.__all__).difference(_get_reexported_names(tested_module)):
156 obj = public_api[obj_name]
157 if obj.aliases and ( 157 ↛ 160line 157 didn't jump to line 160 because the condition on line 157 was never true
158 public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)]
159 ):
160 multiple_locations[obj.path] = public_aliases
161 assert not multiple_locations, "Multiple public locations:\n" + "\n".join(
162 f"{path}: {aliases}" for path, aliases in multiple_locations.items()
163 )
166@_test_all_modules
167def test_api_matches_inventory(inventory: Inventory, tested_module: ModuleType) -> None:
168 """All public objects are added to the inventory."""
169 ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"}
170 ignore_names.update(_get_reexported_names(tested_module))
171 ignore_paths = {"griffe.DataclassesExtension.*", "griffe.UnpackTypedDictExtension.*"}
172 loader = _load_modules(tested_module)
173 public_api = _get_public_api(tested_module, loader=loader)
174 public_objects = _get_public_objects(public_api)
175 not_in_inventory = [
176 f"{obj.relative_filepath}:{obj.lineno}: {obj.path}"
177 for obj in public_objects
178 if (
179 obj.name not in ignore_names
180 and not any(fnmatch(obj.path, pat) for pat in ignore_paths)
181 and obj.path not in inventory
182 )
183 ]
184 msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}"
185 assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory)))
188def test_inventory_matches_api(inventory: Inventory) -> None:
189 """The inventory doesn't contain any additional Python object."""
190 loader = _load_modules(*TESTED_MODULES)
191 not_in_api = []
192 public_objects = []
193 public_api_paths = set()
195 for tested_module in TESTED_MODULES:
196 public_api = _get_public_api(tested_module, loader=loader)
197 module_public_objects = _get_public_objects(public_api)
198 public_api_paths.add(tested_module.__name__)
199 public_api_paths.update({obj.path for obj in module_public_objects})
200 public_objects.extend(module_public_objects)
202 for item in inventory.values():
203 if item.domain == "py" and "(" not in item.name:
204 obj = loader.modules_collection[item.name]
205 if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases):
206 not_in_api.append(item.name)
208 msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}"
209 assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api)))
212@_test_all_modules
213def test_no_module_docstrings_in_internal_api(tested_module: ModuleType) -> None:
214 """No module docstrings should be written in our internal API.
216 The reasoning is that docstrings are addressed to users of the public API,
217 but internal modules are not exposed to users, so they should not have docstrings.
218 """
219 internal_api = _get_internal_api(tested_module)
221 def _modules(obj: griffe.Module) -> Iterator[griffe.Module]:
222 for member in obj.modules.values():
223 yield member
224 yield from _modules(member)
226 for obj in _modules(internal_api):
227 assert not obj.docstring