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

1"""Tests for our own API exposition.""" 

2 

3from __future__ import annotations 

4 

5from collections import defaultdict 

6from fnmatch import fnmatch 

7from pathlib import Path 

8from typing import TYPE_CHECKING 

9 

10import pytest 

11from mkdocstrings import Inventory 

12 

13import griffe 

14import griffecli 

15 

16if TYPE_CHECKING: 

17 from collections.abc import Iterable, Iterator 

18 from types import ModuleType 

19 

20 

21TESTED_MODULES = (griffe, griffecli) 

22_test_all_modules = pytest.mark.parametrize("tested_module", TESTED_MODULES) 

23 

24 

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) 

32 

33 

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 

45 

46 

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"] 

51 

52 

53def _get_reexported_names(module: ModuleType) -> Iterable[str]: 

54 return getattr(module, "_REEXPORTED_EXTERNAL_API", ()) 

55 

56 

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__] 

61 

62 

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 

98 

99 

100def _get_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 

101 return list(_yield_public_objects(internal_api, modulelevel=True)) 

102 

103 

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)) 

106 

107 

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 

121 

122 

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)) 

133 

134 

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) 

144 

145 

146@_test_all_modules 

147def test_single_locations(tested_module: ModuleType) -> None: 

148 """All objects have a single public location.""" 

149 

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)) 

152 

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 ) 

164 

165 

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))) 

186 

187 

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() 

194 

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) 

201 

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) 

207 

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))) 

210 

211 

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. 

215 

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) 

220 

221 def _modules(obj: griffe.Module) -> Iterator[griffe.Module]: 

222 for member in obj.modules.values(): 

223 yield member 

224 yield from _modules(member) 

225 

226 for obj in _modules(internal_api): 

227 assert not obj.docstring